├── LICENSE ├── README.md ├── backend ├── .gitignore ├── mempool-config.sample.json ├── package.json ├── src │ ├── api │ │ ├── bitcoin │ │ │ └── electrs-api.ts │ │ ├── blocks.ts │ │ ├── disk-cache.ts │ │ ├── fee-api.ts │ │ ├── fiat-conversion.ts │ │ ├── mempool-blocks.ts │ │ ├── mempool.ts │ │ └── statistics.ts │ ├── database.ts │ ├── index.ts │ ├── interfaces.ts │ └── routes.ts ├── tsconfig.json ├── tslint.json └── yarn.lock └── frontend ├── .editorconfig ├── .gitignore ├── README.md ├── angular.json ├── browserslist ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── karma.conf.js ├── package.json ├── proxy.conf.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.module.ts │ ├── components │ │ ├── about │ │ │ ├── about.component.html │ │ │ ├── about.component.scss │ │ │ └── about.component.ts │ │ ├── address-labels │ │ │ ├── address-labels.component.html │ │ │ ├── address-labels.component.scss │ │ │ ├── address-labels.component.spec.ts │ │ │ └── address-labels.component.ts │ │ ├── address │ │ │ ├── address.component.html │ │ │ ├── address.component.scss │ │ │ └── address.component.ts │ │ ├── amount │ │ │ ├── amount.component.html │ │ │ ├── amount.component.scss │ │ │ ├── amount.component.spec.ts │ │ │ └── amount.component.ts │ │ ├── app │ │ │ ├── app.component.html │ │ │ ├── app.component.scss │ │ │ ├── app.component.spec.ts │ │ │ └── app.component.ts │ │ ├── block │ │ │ ├── block.component.html │ │ │ ├── block.component.scss │ │ │ └── block.component.ts │ │ ├── blockchain-blocks │ │ │ ├── blockchain-blocks.component.html │ │ │ ├── blockchain-blocks.component.scss │ │ │ └── blockchain-blocks.component.ts │ │ ├── blockchain │ │ │ ├── blockchain.component.html │ │ │ ├── blockchain.component.scss │ │ │ └── blockchain.component.ts │ │ ├── clipboard │ │ │ ├── clipboard.component.html │ │ │ ├── clipboard.component.scss │ │ │ ├── clipboard.component.spec.ts │ │ │ └── clipboard.component.ts │ │ ├── explorer │ │ │ ├── explorer.component.html │ │ │ ├── explorer.component.scss │ │ │ ├── explorer.component.spec.ts │ │ │ └── explorer.component.ts │ │ ├── footer │ │ │ ├── footer.component.html │ │ │ ├── footer.component.scss │ │ │ └── footer.component.ts │ │ ├── latest-blocks │ │ │ ├── latest-blocks.component.html │ │ │ ├── latest-blocks.component.scss │ │ │ ├── latest-blocks.component.spec.ts │ │ │ └── latest-blocks.component.ts │ │ ├── latest-transactions │ │ │ ├── latest-transactions.component.html │ │ │ ├── latest-transactions.component.scss │ │ │ ├── latest-transactions.component.spec.ts │ │ │ └── latest-transactions.component.ts │ │ ├── master-page │ │ │ ├── master-page.component.html │ │ │ ├── master-page.component.scss │ │ │ └── master-page.component.ts │ │ ├── mempool-blocks │ │ │ ├── mempool-blocks.component.html │ │ │ ├── mempool-blocks.component.scss │ │ │ └── mempool-blocks.component.ts │ │ ├── qrcode │ │ │ ├── qrcode.component.html │ │ │ ├── qrcode.component.scss │ │ │ ├── qrcode.component.spec.ts │ │ │ └── qrcode.component.ts │ │ ├── search-form │ │ │ ├── search-form.component.html │ │ │ ├── search-form.component.scss │ │ │ ├── search-form.component.spec.ts │ │ │ └── search-form.component.ts │ │ ├── start │ │ │ ├── start.component.html │ │ │ ├── start.component.scss │ │ │ ├── start.component.spec.ts │ │ │ └── start.component.ts │ │ ├── statistics │ │ │ ├── chartist.component.scss │ │ │ ├── chartist.component.ts │ │ │ ├── statistics.component.html │ │ │ ├── statistics.component.scss │ │ │ └── statistics.component.ts │ │ ├── television │ │ │ ├── television.component.html │ │ │ ├── television.component.scss │ │ │ └── television.component.ts │ │ ├── time-since │ │ │ └── time-since.component.ts │ │ ├── transaction │ │ │ ├── transaction.component.html │ │ │ ├── transaction.component.scss │ │ │ └── transaction.component.ts │ │ └── transactions-list │ │ │ ├── transactions-list.component.html │ │ │ ├── transactions-list.component.scss │ │ │ └── transactions-list.component.ts │ ├── interfaces │ │ ├── electrs.interface.ts │ │ ├── node-api.interface.ts │ │ └── websocket.interface.ts │ ├── pipes │ │ ├── bytes-pipe │ │ │ ├── bytes.pipe.ts │ │ │ ├── utils.ts │ │ │ ├── vbytes.pipe.ts │ │ │ └── wubytes.pipe.ts │ │ ├── math-ceil │ │ │ └── math-ceil.pipe.ts │ │ ├── shorten-string-pipe │ │ │ └── shorten-string.pipe.ts │ │ └── time-since │ │ │ └── time-since.pipe.ts │ └── services │ │ ├── api.service.ts │ │ ├── electrs-api.service.ts │ │ ├── state.service.ts │ │ └── websocket.service.ts ├── assets │ ├── .gitkeep │ ├── arrowdown.png │ ├── bitcoin-logo.png │ ├── btc-qr-code-segwit.png │ ├── btc-qr-code.png │ ├── clippy.svg │ ├── divider-new.png │ ├── expand.png │ ├── favicons │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── apple-icon-precomposed.png │ │ ├── apple-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ └── ms-icon-70x70.png │ ├── mempool-space-logo.png │ ├── mempool-tube.png │ └── paynym-code.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── tslint.json └── yarn.lock /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Simon Lindh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mempool.space 2 | 🚨This is beta software, and may have issues!🚨 3 | Please help us test and report bugs to our GitHub issue tracker. 4 | 5 | Mempool visualizer for the Bitcoin blockchain. Live demo: https://mempool.space/ 6 | ![blockchain](https://pbs.twimg.com/media/EAETXWAU8AAj4IP?format=jpg&name=4096x4096) 7 | ![mempool](https://pbs.twimg.com/media/EAETXWCU4AAv2v-?format=jpg&name=4096x4096) 8 | 9 | ## Dependencies 10 | 11 | * Bitcoin (full node required, no pruning, txindex=1) 12 | * NodeJS (official stable LTS) 13 | * MySQL or MariaDB (default config) 14 | * Nginx (use supplied nginx.conf) 15 | 16 | ## Bitcoin Core (bitcoind) 17 | 18 | Enable RPC and txindex in bitcoin.conf 19 | 20 | ```bash 21 | rpcuser=mempool 22 | rpcpassword=71b61986da5b03a5694d7c7d5165ece5 23 | txindex=1 24 | ``` 25 | 26 | ## NodeJS 27 | 28 | Install dependencies and build code: 29 | 30 | ```bash 31 | cd mempool.space 32 | 33 | # Install TypeScript Globally 34 | npm install -g typescript 35 | 36 | # Frontend 37 | cd frontend 38 | npm install 39 | npm run build 40 | 41 | # Backend 42 | cd ../backend/ 43 | npm install 44 | npm run build 45 | ``` 46 | 47 | ## Mempool Configuration 48 | In the `backend` folder, make a copy of the sample config and modify it to fit your settings. 49 | 50 | ```bash 51 | cp mempool-config.sample.json mempool-config.json 52 | ``` 53 | 54 | Edit `mempool-config.json` to add your Bitcoin Core node RPC credentials: 55 | ```bash 56 | "BITCOIN_NODE_HOST": "192.168.1.5", 57 | "BITCOIN_NODE_PORT": 8332, 58 | "BITCOIN_NODE_USER": "mempool", 59 | "BITCOIN_NODE_PASS": "71b61986da5b03a5694d7c7d5165ece5", 60 | ``` 61 | 62 | ## MySQL 63 | 64 | Install MariaDB: 65 | 66 | ```bash 67 | # Linux 68 | apt-get install mariadb-server mariadb-client 69 | 70 | # macOS 71 | brew install mariadb 72 | brew services start mariadb 73 | ``` 74 | 75 | Create database and grant privileges: 76 | ```bash 77 | MariaDB [(none)]> drop database mempool; 78 | Query OK, 0 rows affected (0.00 sec) 79 | 80 | MariaDB [(none)]> create database mempool; 81 | Query OK, 1 row affected (0.00 sec) 82 | 83 | MariaDB [(none)]> grant all privileges on mempool.* to 'mempool' identified by 'mempool'; 84 | Query OK, 0 rows affected (0.00 sec) 85 | ``` 86 | 87 | From the root folder, initialize database structure: 88 | 89 | ```bash 90 | mysql -u mempool -p mempool < mariadb-structure.sql 91 | ``` 92 | 93 | ## Running (Backend) 94 | 95 | Create an initial empty cache and start the app: 96 | 97 | ```bash 98 | touch cache.json 99 | npm run start # node dist/index.js 100 | ``` 101 | 102 | After starting you should see: 103 | 104 | ```bash 105 | Server started on port 8999 :) 106 | New block found (#586498)! 0 of 1986 found in mempool. 1985 not found. 107 | New block found (#586499)! 0 of 1094 found in mempool. 1093 not found. 108 | New block found (#586500)! 0 of 2735 found in mempool. 2734 not found. 109 | New block found (#586501)! 0 of 2675 found in mempool. 2674 not found. 110 | New block found (#586502)! 0 of 975 found in mempool. 974 not found. 111 | New block found (#586503)! 0 of 2130 found in mempool. 2129 not found. 112 | New block found (#586504)! 0 of 2770 found in mempool. 2769 not found. 113 | New block found (#586505)! 0 of 2759 found in mempool. 2758 not found. 114 | Updating mempool 115 | Calculated fee for transaction 1 / 3257 116 | Calculated fee for transaction 2 / 3257 117 | Calculated fee for transaction 3 / 3257 118 | Calculated fee for transaction 4 / 3257 119 | Calculated fee for transaction 5 / 3257 120 | Calculated fee for transaction 6 / 3257 121 | Calculated fee for transaction 7 / 3257 122 | Calculated fee for transaction 8 / 3257 123 | Calculated fee for transaction 9 / 3257 124 | ``` 125 | You need to wait for at least *8 blocks to be mined*, so please wait ~80 minutes. 126 | The backend also needs to index transactions, calculate fees, etc. 127 | When it's ready you will see output like this: 128 | 129 | ```bash 130 | Mempool updated in 0.189 seconds 131 | Updating mempool 132 | Mempool updated in 0.096 seconds 133 | Updating mempool 134 | Mempool updated in 0.099 seconds 135 | Updating mempool 136 | Calculated fee for transaction 1 / 10 137 | Calculated fee for transaction 2 / 10 138 | Calculated fee for transaction 3 / 10 139 | Calculated fee for transaction 4 / 10 140 | Calculated fee for transaction 5 / 10 141 | Calculated fee for transaction 6 / 10 142 | Calculated fee for transaction 7 / 10 143 | Calculated fee for transaction 8 / 10 144 | Calculated fee for transaction 9 / 10 145 | Calculated fee for transaction 10 / 10 146 | Mempool updated in 0.243 seconds 147 | Updating mempool 148 | ``` 149 | 150 | ## nginx + CertBot (LetsEncrypt) 151 | Setup nginx using the supplied nginx.conf 152 | 153 | ```bash 154 | # install nginx and certbot 155 | apt-get install -y nginx python-certbot-nginx 156 | 157 | # replace example.com with your domain name 158 | certbot --nginx -d example.com 159 | 160 | # install the mempool configuration for nginx 161 | cp nginx.conf /etc/nginx/nginx.conf 162 | 163 | # edit the installed nginx.conf, and replace all 164 | # instances of example.com with your domain name 165 | ``` 166 | Make sure you can access https:/// in browser before proceeding 167 | 168 | 169 | ## Running (Frontend) 170 | 171 | Build the frontend static HTML/CSS/JS, rsync the output into nginx folder: 172 | 173 | ```bash 174 | cd frontend/ 175 | npm run build 176 | sudo rsync -av --delete dist/mempool/ /var/www/html/ 177 | ``` 178 | 179 | ## Try It Out 180 | 181 | If everything went okay you should see the beautiful mempool :grin: 182 | 183 | If you get stuck on "loading blocks", this means the websocket can't connect. 184 | Check your nginx proxy setup, firewalls, etc. and open an issue if you need help. 185 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # production config 4 | mempool-config.json 5 | 6 | # compiled output 7 | /dist 8 | /tmp 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # IDEs and editors 14 | /.idea 15 | .project 16 | .classpath 17 | .c9/ 18 | *.launch 19 | .settings/ 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage/* 32 | /libpeerconnection.log 33 | npm-debug.log 34 | testem.log 35 | /typings 36 | 37 | # e2e 38 | /e2e/*.js 39 | /e2e/*.map 40 | 41 | #System Files 42 | .DS_Store 43 | Thumbs.db 44 | 45 | cache.json 46 | -------------------------------------------------------------------------------- /backend/mempool-config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "HTTP_PORT": 8999, 3 | "DB_HOST": "localhost", 4 | "DB_PORT": 3306, 5 | "DB_USER": "mempool", 6 | "DB_PASSWORD": "mempool", 7 | "DB_DATABASE": "mempool", 8 | "API_ENDPOINT": "/api/v1/", 9 | "ELECTRS_POLL_RATE_MS": 2000, 10 | "MEMPOOL_REFRESH_RATE_MS": 10000, 11 | "DEFAULT_PROJECTED_BLOCKS_AMOUNT": 3, 12 | "KEEP_BLOCK_AMOUNT": 24, 13 | "INITIAL_BLOCK_AMOUNT": 8, 14 | "TX_PER_SECOND_SPAN_SECONDS": 150, 15 | "ELECTRS_API_URL": "https://www.blockstream.info/testnet/api", 16 | "SSL": false, 17 | "SSL_CERT_FILE_PATH": "/etc/letsencrypt/live/mysite/fullchain.pem", 18 | "SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem" 19 | } 20 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mempool-space-explorer-backend", 3 | "version": "1.0.0", 4 | "description": "Mempool space backend", 5 | "main": "index.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "npm run build && node dist/index.js" 9 | }, 10 | "license": "MIT", 11 | "dependencies": { 12 | "compression": "^1.7.4", 13 | "express": "^4.17.1", 14 | "mysql2": "^1.6.1", 15 | "request": "^2.88.0", 16 | "ws": "^7.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/compression": "^1.0.1", 20 | "@types/express": "^4.17.2", 21 | "@types/request": "^2.48.2", 22 | "@types/ws": "^6.0.4", 23 | "tslint": "^5.11.0", 24 | "typescript": "~3.6.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/api/bitcoin/electrs-api.ts: -------------------------------------------------------------------------------- 1 | const config = require('../../../mempool-config.json'); 2 | import { Transaction, Block, MempoolInfo } from '../../interfaces'; 3 | import * as request from 'request'; 4 | 5 | class ElectrsApi { 6 | 7 | constructor() { 8 | } 9 | 10 | getMempoolInfo(): Promise { 11 | return new Promise((resolve, reject) => { 12 | request(config.ELECTRS_API_URL + '/mempool', { json: true, timeout: 10000 }, (err, res, response) => { 13 | if (err) { 14 | reject(err); 15 | } else if (res.statusCode !== 200) { 16 | reject(response); 17 | } else { 18 | if (!response.count) { 19 | reject('Empty data'); 20 | return; 21 | } 22 | resolve({ 23 | size: response.count, 24 | bytes: response.vsize, 25 | }); 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | getRawMempool(): Promise { 32 | return new Promise((resolve, reject) => { 33 | request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000, forever: true }, (err, res, response) => { 34 | if (err) { 35 | reject(err); 36 | } else if (res.statusCode !== 200) { 37 | reject(response); 38 | } else { 39 | resolve(response); 40 | } 41 | }); 42 | }); 43 | } 44 | 45 | getRawTransaction(txId: string): Promise { 46 | return new Promise((resolve, reject) => { 47 | request(config.ELECTRS_API_URL + '/tx/' + txId, { json: true, timeout: 10000, forever: true }, (err, res, response) => { 48 | if (err) { 49 | reject(err); 50 | } else if (res.statusCode !== 200) { 51 | reject(response); 52 | } else { 53 | resolve(response); 54 | } 55 | }); 56 | }); 57 | } 58 | 59 | getBlockHeightTip(): Promise { 60 | return new Promise((resolve, reject) => { 61 | request(config.ELECTRS_API_URL + '/blocks/tip/height', { json: true, timeout: 10000 }, (err, res, response) => { 62 | if (err) { 63 | reject(err); 64 | } else if (res.statusCode !== 200) { 65 | reject(response); 66 | } else { 67 | resolve(response); 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | getTxIdsForBlock(hash: string): Promise { 74 | return new Promise((resolve, reject) => { 75 | request(config.ELECTRS_API_URL + '/block/' + hash + '/txids', { json: true, timeout: 10000 }, (err, res, response) => { 76 | if (err) { 77 | reject(err); 78 | } else if (res.statusCode !== 200) { 79 | reject(response); 80 | } else { 81 | resolve(response); 82 | } 83 | }); 84 | }); 85 | } 86 | 87 | getBlockHash(height: number): Promise { 88 | return new Promise((resolve, reject) => { 89 | request(config.ELECTRS_API_URL + '/block-height/' + height, { json: true, timeout: 10000 }, (err, res, response) => { 90 | if (err) { 91 | reject(err); 92 | } else if (res.statusCode !== 200) { 93 | reject(response); 94 | } else { 95 | resolve(response); 96 | } 97 | }); 98 | }); 99 | } 100 | 101 | getBlocksFromHeight(height: number): Promise { 102 | return new Promise((resolve, reject) => { 103 | request(config.ELECTRS_API_URL + '/blocks/' + height, { json: true, timeout: 10000 }, (err, res, response) => { 104 | if (err) { 105 | reject(err); 106 | } else if (res.statusCode !== 200) { 107 | reject(response); 108 | } else { 109 | resolve(response); 110 | } 111 | }); 112 | }); 113 | } 114 | 115 | getBlock(hash: string): Promise { 116 | return new Promise((resolve, reject) => { 117 | request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => { 118 | if (err) { 119 | reject(err); 120 | } else if (res.statusCode !== 200) { 121 | reject(response); 122 | } else { 123 | resolve(response); 124 | } 125 | }); 126 | }); 127 | } 128 | } 129 | 130 | export default new ElectrsApi(); 131 | -------------------------------------------------------------------------------- /backend/src/api/blocks.ts: -------------------------------------------------------------------------------- 1 | const config = require('../../mempool-config.json'); 2 | import bitcoinApi from './bitcoin/electrs-api'; 3 | import memPool from './mempool'; 4 | import { Block, TransactionExtended } from '../interfaces'; 5 | 6 | class Blocks { 7 | private blocks: Block[] = []; 8 | private currentBlockHeight = 0; 9 | private newBlockCallback: Function = () => {}; 10 | 11 | constructor() { } 12 | 13 | public getBlocks(): Block[] { 14 | return this.blocks; 15 | } 16 | 17 | public setNewBlockCallback(fn: Function) { 18 | this.newBlockCallback = fn; 19 | } 20 | 21 | public async updateBlocks() { 22 | try { 23 | const blockHeightTip = await bitcoinApi.getBlockHeightTip(); 24 | 25 | if (this.blocks.length === 0) { 26 | this.currentBlockHeight = blockHeightTip - config.INITIAL_BLOCK_AMOUNT; 27 | } else { 28 | this.currentBlockHeight = this.blocks[this.blocks.length - 1].height; 29 | } 30 | 31 | while (this.currentBlockHeight < blockHeightTip) { 32 | if (this.currentBlockHeight === 0) { 33 | this.currentBlockHeight = blockHeightTip; 34 | } else { 35 | this.currentBlockHeight++; 36 | console.log(`New block found (#${this.currentBlockHeight})!`); 37 | } 38 | 39 | const blockHash = await bitcoinApi.getBlockHash(this.currentBlockHeight); 40 | const block = await bitcoinApi.getBlock(blockHash); 41 | const txIds = await bitcoinApi.getTxIdsForBlock(blockHash); 42 | 43 | const mempool = memPool.getMempool(); 44 | let found = 0; 45 | let notFound = 0; 46 | 47 | const transactions: TransactionExtended[] = []; 48 | 49 | for (let i = 1; i < txIds.length; i++) { 50 | if (mempool[txIds[i]]) { 51 | transactions.push(mempool[txIds[i]]); 52 | found++; 53 | } else { 54 | console.log(`Fetching block tx ${i} of ${txIds.length}`); 55 | const tx = await memPool.getTransactionExtended(txIds[i]); 56 | if (tx) { 57 | transactions.push(tx); 58 | } 59 | notFound++; 60 | } 61 | } 62 | 63 | transactions.sort((a, b) => b.feePerVsize - a.feePerVsize); 64 | block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize)); 65 | block.feeRange = this.getFeesInRange(transactions, 8); 66 | 67 | this.blocks.push(block); 68 | if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) { 69 | this.blocks.shift(); 70 | } 71 | 72 | this.newBlockCallback(block, txIds, transactions); 73 | } 74 | 75 | } catch (err) { 76 | console.log('updateBlocks error', err); 77 | } 78 | } 79 | 80 | private median(numbers: number[]) { 81 | let medianNr = 0; 82 | const numsLen = numbers.length; 83 | numbers.sort(); 84 | if (numsLen % 2 === 0) { 85 | medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; 86 | } else { 87 | medianNr = numbers[(numsLen - 1) / 2]; 88 | } 89 | return medianNr; 90 | } 91 | 92 | private getFeesInRange(transactions: any[], rangeLength: number) { 93 | const arr = [transactions[transactions.length - 1].feePerVsize]; 94 | const chunk = 1 / (rangeLength - 1); 95 | let itemsToAdd = rangeLength - 2; 96 | 97 | while (itemsToAdd > 0) { 98 | arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].feePerVsize); 99 | itemsToAdd--; 100 | } 101 | 102 | arr.push(transactions[0].feePerVsize); 103 | return arr; 104 | } 105 | } 106 | 107 | export default new Blocks(); 108 | -------------------------------------------------------------------------------- /backend/src/api/disk-cache.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | class DiskCache { 4 | static FILE_NAME = './cache.json'; 5 | constructor() { } 6 | 7 | saveData(dataBlob: string) { 8 | fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8'); 9 | } 10 | 11 | loadData(): string { 12 | return fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); 13 | } 14 | } 15 | 16 | export default new DiskCache(); 17 | -------------------------------------------------------------------------------- /backend/src/api/fee-api.ts: -------------------------------------------------------------------------------- 1 | import projectedBlocks from './mempool-blocks'; 2 | import { DB } from '../database'; 3 | 4 | class FeeApi { 5 | constructor() { } 6 | 7 | public getRecommendedFee() { 8 | const pBlocks = projectedBlocks.getMempoolBlocks(); 9 | if (!pBlocks.length) { 10 | return { 11 | 'fastestFee': 0, 12 | 'halfHourFee': 0, 13 | 'hourFee': 0, 14 | }; 15 | } 16 | let firstMedianFee = Math.ceil(pBlocks[0].medianFee); 17 | 18 | if (pBlocks.length === 1 && pBlocks[0].blockVSize <= 500000) { 19 | firstMedianFee = 1; 20 | } 21 | 22 | const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee; 23 | const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee; 24 | 25 | return { 26 | 'fastestFee': firstMedianFee, 27 | 'halfHourFee': secondMedianFee, 28 | 'hourFee': thirdMedianFee, 29 | }; 30 | } 31 | 32 | public async $getTransactionsForBlock(blockHeight: number): Promise { 33 | try { 34 | const connection = await DB.pool.getConnection(); 35 | const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`; 36 | const [rows] = await connection.query(query, [blockHeight]); 37 | connection.release(); 38 | return rows; 39 | } catch (e) { 40 | console.log('$getTransactionsForBlock() error', e); 41 | return []; 42 | } 43 | } 44 | 45 | } 46 | 47 | export default new FeeApi(); 48 | -------------------------------------------------------------------------------- /backend/src/api/fiat-conversion.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'request'; 2 | 3 | class FiatConversion { 4 | private tickers = { 5 | 'BTCUSD': { 6 | 'USD': 4110.78 7 | }, 8 | }; 9 | 10 | constructor() { } 11 | 12 | public startService() { 13 | setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60); 14 | this.updateCurrency(); 15 | } 16 | 17 | public getTickers() { 18 | return this.tickers; 19 | } 20 | 21 | private updateCurrency() { 22 | request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => { 23 | if (err) { return console.log(err); } 24 | if (body && body.data) { 25 | this.tickers = body.data; 26 | } 27 | }); 28 | } 29 | } 30 | 31 | export default new FiatConversion(); 32 | -------------------------------------------------------------------------------- /backend/src/api/mempool-blocks.ts: -------------------------------------------------------------------------------- 1 | const config = require('../../mempool-config.json'); 2 | import { MempoolBlock, TransactionExtended } from '../interfaces'; 3 | 4 | class MempoolBlocks { 5 | private mempoolBlocks: MempoolBlock[] = []; 6 | 7 | constructor() {} 8 | 9 | public getMempoolBlocks(): MempoolBlock[] { 10 | return this.mempoolBlocks; 11 | } 12 | 13 | public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }): void { 14 | const latestMempool = memPool; 15 | const memPoolArray: TransactionExtended[] = []; 16 | for (const i in latestMempool) { 17 | if (latestMempool.hasOwnProperty(i)) { 18 | memPoolArray.push(latestMempool[i]); 19 | } 20 | } 21 | memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize); 22 | const transactionsSorted = memPoolArray.filter((tx) => tx.feePerVsize); 23 | this.mempoolBlocks = this.calculateMempoolBlocks(transactionsSorted); 24 | } 25 | 26 | private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlock[] { 27 | const mempoolBlocks: MempoolBlock[] = []; 28 | let blockWeight = 0; 29 | let blockSize = 0; 30 | let transactions: TransactionExtended[] = []; 31 | transactionsSorted.forEach((tx) => { 32 | if (blockWeight + tx.vsize < 1000000 || mempoolBlocks.length === config.DEFAULT_PROJECTED_BLOCKS_AMOUNT) { 33 | blockWeight += tx.vsize; 34 | blockSize += tx.size; 35 | transactions.push(tx); 36 | } else { 37 | mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); 38 | blockWeight = 0; 39 | blockSize = 0; 40 | transactions = []; 41 | } 42 | }); 43 | if (transactions.length) { 44 | mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); 45 | } 46 | return mempoolBlocks; 47 | } 48 | 49 | private dataToMempoolBlocks(transactions: TransactionExtended[], blockSize: number, blockVSize: number, blocksIndex: number): MempoolBlock { 50 | let rangeLength = 3; 51 | if (blocksIndex === 0) { 52 | rangeLength = 8; 53 | } 54 | if (transactions.length > 4000) { 55 | rangeLength = 5; 56 | } else if (transactions.length > 10000) { 57 | rangeLength = 8; 58 | } else if (transactions.length > 25000) { 59 | rangeLength = 10; 60 | } 61 | return { 62 | blockSize: blockSize, 63 | blockVSize: blockVSize, 64 | nTx: transactions.length, 65 | medianFee: this.median(transactions.map((tx) => tx.feePerVsize)), 66 | feeRange: this.getFeesInRange(transactions, rangeLength), 67 | }; 68 | } 69 | 70 | private median(numbers: number[]) { 71 | let medianNr = 0; 72 | const numsLen = numbers.length; 73 | numbers.sort(); 74 | if (numsLen % 2 === 0) { 75 | medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; 76 | } else { 77 | medianNr = numbers[(numsLen - 1) / 2]; 78 | } 79 | return medianNr; 80 | } 81 | 82 | private getFeesInRange(transactions: TransactionExtended[], rangeLength: number) { 83 | const arr = [transactions[transactions.length - 1].feePerVsize]; 84 | const chunk = 1 / (rangeLength - 1); 85 | let itemsToAdd = rangeLength - 2; 86 | 87 | while (itemsToAdd > 0) { 88 | arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].feePerVsize); 89 | itemsToAdd--; 90 | } 91 | 92 | arr.push(transactions[0].feePerVsize); 93 | return arr; 94 | } 95 | } 96 | 97 | export default new MempoolBlocks(); 98 | -------------------------------------------------------------------------------- /backend/src/api/mempool.ts: -------------------------------------------------------------------------------- 1 | const config = require('../../mempool-config.json'); 2 | import bitcoinApi from './bitcoin/electrs-api'; 3 | import { MempoolInfo, TransactionExtended, Transaction } from '../interfaces'; 4 | 5 | class Mempool { 6 | private mempoolCache: any = {}; 7 | private mempoolInfo: MempoolInfo | undefined; 8 | private mempoolChangedCallback: Function | undefined; 9 | 10 | private txPerSecondArray: number[] = []; 11 | private txPerSecond: number = 0; 12 | 13 | private vBytesPerSecondArray: any[] = []; 14 | private vBytesPerSecond: number = 0; 15 | 16 | constructor() { 17 | setInterval(this.updateTxPerSecond.bind(this), 1000); 18 | } 19 | 20 | public setMempoolChangedCallback(fn: Function) { 21 | this.mempoolChangedCallback = fn; 22 | } 23 | 24 | public getMempool(): { [txid: string]: TransactionExtended } { 25 | return this.mempoolCache; 26 | } 27 | 28 | public setMempool(mempoolData: any) { 29 | this.mempoolCache = mempoolData; 30 | if (this.mempoolChangedCallback && mempoolData) { 31 | this.mempoolChangedCallback(mempoolData); 32 | } 33 | } 34 | 35 | public async updateMemPoolInfo() { 36 | try { 37 | this.mempoolInfo = await bitcoinApi.getMempoolInfo(); 38 | } catch (err) { 39 | console.log('Error getMempoolInfo', err); 40 | } 41 | } 42 | 43 | public getMempoolInfo(): MempoolInfo | undefined { 44 | return this.mempoolInfo; 45 | } 46 | 47 | public getTxPerSecond(): number { 48 | return this.txPerSecond; 49 | } 50 | 51 | public getVBytesPerSecond(): number { 52 | return this.vBytesPerSecond; 53 | } 54 | 55 | public async getTransactionExtended(txId: string): Promise { 56 | try { 57 | const transaction: Transaction = await bitcoinApi.getRawTransaction(txId); 58 | return Object.assign({ 59 | vsize: transaction.weight / 4, 60 | feePerVsize: transaction.fee / (transaction.weight / 4), 61 | }, transaction); 62 | } catch (e) { 63 | console.log(txId + ' not found'); 64 | return false; 65 | } 66 | } 67 | 68 | public async updateMempool() { 69 | console.log('Updating mempool'); 70 | const start = new Date().getTime(); 71 | let hasChange: boolean = false; 72 | let txCount = 0; 73 | try { 74 | const transactions = await bitcoinApi.getRawMempool(); 75 | const diff = transactions.length - Object.keys(this.mempoolCache).length; 76 | const newTransactions: TransactionExtended[] = []; 77 | 78 | for (const txid of transactions) { 79 | if (!this.mempoolCache[txid]) { 80 | const transaction = await this.getTransactionExtended(txid); 81 | if (transaction) { 82 | this.mempoolCache[txid] = transaction; 83 | txCount++; 84 | this.txPerSecondArray.push(new Date().getTime()); 85 | this.vBytesPerSecondArray.push({ 86 | unixTime: new Date().getTime(), 87 | vSize: transaction.vsize, 88 | }); 89 | hasChange = true; 90 | if (diff > 0) { 91 | console.log('Fetched transaction ' + txCount + ' / ' + diff); 92 | } else { 93 | console.log('Fetched transaction ' + txCount); 94 | } 95 | newTransactions.push(transaction); 96 | } else { 97 | console.log('Error finding transaction in mempool.'); 98 | } 99 | } 100 | 101 | if ((new Date().getTime()) - start > config.MEMPOOL_REFRESH_RATE_MS) { 102 | break; 103 | } 104 | } 105 | 106 | // Replace mempool to clear already confirmed transactions 107 | const newMempool: any = {}; 108 | transactions.forEach((tx) => { 109 | if (this.mempoolCache[tx]) { 110 | newMempool[tx] = this.mempoolCache[tx]; 111 | } else { 112 | hasChange = true; 113 | } 114 | }); 115 | 116 | this.mempoolCache = newMempool; 117 | 118 | if (hasChange && this.mempoolChangedCallback) { 119 | this.mempoolChangedCallback(this.mempoolCache, newTransactions); 120 | } 121 | 122 | const end = new Date().getTime(); 123 | const time = end - start; 124 | console.log('Mempool updated in ' + time / 1000 + ' seconds'); 125 | } catch (err) { 126 | console.log('getRawMempool error.', err); 127 | } 128 | } 129 | 130 | private updateTxPerSecond() { 131 | const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS); 132 | this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); 133 | this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0; 134 | 135 | this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan); 136 | if (this.vBytesPerSecondArray.length) { 137 | this.vBytesPerSecond = Math.round( 138 | this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS 139 | ); 140 | } 141 | } 142 | } 143 | 144 | export default new Mempool(); 145 | -------------------------------------------------------------------------------- /backend/src/database.ts: -------------------------------------------------------------------------------- 1 | const config = require('../mempool-config.json'); 2 | import { createPool } from 'mysql2/promise'; 3 | 4 | export class DB { 5 | static pool = createPool({ 6 | host: config.DB_HOST, 7 | port: config.DB_PORT, 8 | database: config.DB_DATABASE, 9 | user: config.DB_USER, 10 | password: config.DB_PASSWORD, 11 | connectionLimit: 10, 12 | supportBigNumbers: true, 13 | }); 14 | } 15 | 16 | export async function checkDbConnection() { 17 | try { 18 | const connection = await DB.pool.getConnection(); 19 | console.log('MySQL connection established.'); 20 | connection.release(); 21 | } catch (e) { 22 | console.log('Could not connect to MySQL.'); 23 | console.log(e); 24 | process.exit(1); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface MempoolInfo { 2 | size: number; 3 | bytes: number; 4 | usage?: number; 5 | maxmempool?: number; 6 | mempoolminfee?: number; 7 | minrelaytxfee?: number; 8 | } 9 | 10 | export interface MempoolBlock { 11 | blockSize: number; 12 | blockVSize: number; 13 | nTx: number; 14 | medianFee: number; 15 | feeRange: number[]; 16 | } 17 | 18 | export interface Transaction { 19 | txid: string; 20 | version: number; 21 | locktime: number; 22 | fee: number; 23 | size: number; 24 | weight: number; 25 | vin: Vin[]; 26 | vout: Vout[]; 27 | status: Status; 28 | } 29 | 30 | export interface TransactionExtended extends Transaction { 31 | txid: string; 32 | fee: number; 33 | size: number; 34 | vsize: number; 35 | feePerVsize: number; 36 | } 37 | 38 | export interface Prevout { 39 | scriptpubkey: string; 40 | scriptpubkey_asm: string; 41 | scriptpubkey_type: string; 42 | scriptpubkey_address: string; 43 | value: number; 44 | } 45 | 46 | export interface Vin { 47 | txid: string; 48 | vout: number; 49 | prevout: Prevout; 50 | scriptsig: string; 51 | scriptsig_asm: string; 52 | inner_redeemscript_asm?: string; 53 | is_coinbase: boolean; 54 | sequence: any; 55 | witness?: string[]; 56 | inner_witnessscript_asm?: string; 57 | } 58 | 59 | export interface Vout { 60 | scriptpubkey: string; 61 | scriptpubkey_asm: string; 62 | scriptpubkey_type: string; 63 | scriptpubkey_address: string; 64 | value: number; 65 | } 66 | 67 | export interface Status { 68 | confirmed: boolean; 69 | block_height?: number; 70 | block_hash?: string; 71 | block_time?: number; 72 | } 73 | 74 | export interface Block { 75 | id: string; 76 | height: number; 77 | version: number; 78 | timestamp: number; 79 | tx_count: number; 80 | size: number; 81 | weight: number; 82 | merkle_root: string; 83 | previousblockhash: string; 84 | nonce: any; 85 | bits: number; 86 | 87 | medianFee?: number; 88 | feeRange?: number[]; 89 | } 90 | 91 | export interface Address { 92 | address: string; 93 | chain_stats: ChainStats; 94 | mempool_stats: MempoolStats; 95 | } 96 | 97 | export interface ChainStats { 98 | funded_txo_count: number; 99 | funded_txo_sum: number; 100 | spent_txo_count: number; 101 | spent_txo_sum: number; 102 | tx_count: number; 103 | } 104 | 105 | export interface MempoolStats { 106 | funded_txo_count: number; 107 | funded_txo_sum: number; 108 | spent_txo_count: number; 109 | spent_txo_sum: number; 110 | tx_count: number; 111 | } 112 | 113 | export interface Statistic { 114 | id?: number; 115 | added: string; 116 | unconfirmed_transactions: number; 117 | tx_per_second: number; 118 | vbytes_per_second: number; 119 | total_fee: number; 120 | mempool_byte_weight: number; 121 | fee_data: string; 122 | 123 | vsize_1: number; 124 | vsize_2: number; 125 | vsize_3: number; 126 | vsize_4: number; 127 | vsize_5: number; 128 | vsize_6: number; 129 | vsize_8: number; 130 | vsize_10: number; 131 | vsize_12: number; 132 | vsize_15: number; 133 | vsize_20: number; 134 | vsize_30: number; 135 | vsize_40: number; 136 | vsize_50: number; 137 | vsize_60: number; 138 | vsize_70: number; 139 | vsize_80: number; 140 | vsize_90: number; 141 | vsize_100: number; 142 | vsize_125: number; 143 | vsize_150: number; 144 | vsize_175: number; 145 | vsize_200: number; 146 | vsize_250: number; 147 | vsize_300: number; 148 | vsize_350: number; 149 | vsize_400: number; 150 | vsize_500: number; 151 | vsize_600: number; 152 | vsize_700: number; 153 | vsize_800: number; 154 | vsize_900: number; 155 | vsize_1000: number; 156 | vsize_1200: number; 157 | vsize_1400: number; 158 | vsize_1600: number; 159 | vsize_1800: number; 160 | vsize_2000: number; 161 | } 162 | 163 | export interface OptimizedStatistic { 164 | id: number; 165 | added: string; 166 | unconfirmed_transactions: number; 167 | tx_per_second: number; 168 | vbytes_per_second: number; 169 | total_fee: number; 170 | mempool_byte_weight: number; 171 | vsizes: number[]; 172 | } 173 | 174 | export interface Outspend { 175 | spent: boolean; 176 | txid: string; 177 | vin: number; 178 | status: Status; 179 | } 180 | 181 | -------------------------------------------------------------------------------- /backend/src/routes.ts: -------------------------------------------------------------------------------- 1 | import statistics from './api/statistics'; 2 | import feeApi from './api/fee-api'; 3 | import mempoolBlocks from './api/mempool-blocks'; 4 | 5 | class Routes { 6 | private cache = {}; 7 | 8 | constructor() { 9 | this.createCache(); 10 | setInterval(this.createCache.bind(this), 600000); 11 | } 12 | 13 | private async createCache() { 14 | this.cache['24h'] = await statistics.$list24H(); 15 | this.cache['1w'] = await statistics.$list1W(); 16 | this.cache['1m'] = await statistics.$list1M(); 17 | this.cache['3m'] = await statistics.$list3M(); 18 | this.cache['6m'] = await statistics.$list6M(); 19 | this.cache['1y'] = await statistics.$list1Y(); 20 | console.log('Statistics cache created'); 21 | } 22 | 23 | public async get2HStatistics(req, res) { 24 | const result = await statistics.$list2H(); 25 | res.send(result); 26 | } 27 | 28 | public get24HStatistics(req, res) { 29 | res.send(this.cache['24h']); 30 | } 31 | 32 | public get1WHStatistics(req, res) { 33 | res.send(this.cache['1w']); 34 | } 35 | 36 | public get1MStatistics(req, res) { 37 | res.send(this.cache['1m']); 38 | } 39 | 40 | public get3MStatistics(req, res) { 41 | res.send(this.cache['3m']); 42 | } 43 | 44 | public get6MStatistics(req, res) { 45 | res.send(this.cache['6m']); 46 | } 47 | 48 | public get1YStatistics(req, res) { 49 | res.send(this.cache['1y']); 50 | } 51 | 52 | public async getRecommendedFees(req, res) { 53 | const result = feeApi.getRecommendedFee(); 54 | res.send(result); 55 | } 56 | 57 | public async getMempoolBlocks(req, res) { 58 | try { 59 | const result = await mempoolBlocks.getMempoolBlocks(); 60 | res.send(result); 61 | } catch (e) { 62 | res.status(500).send(e.message); 63 | } 64 | } 65 | } 66 | 67 | export default new Routes(); 68 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2015", 5 | "strict": true, 6 | "noImplicitAny": false, 7 | "sourceMap": false, 8 | "outDir": "dist", 9 | "moduleResolution": "node", 10 | "typeRoots": [ 11 | "node_modules/@types" 12 | ] 13 | }, 14 | "include": [ 15 | "src/**/*.ts" 16 | ], 17 | "exclude": [ 18 | "dist/**" 19 | ] 20 | } -------------------------------------------------------------------------------- /backend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "arrow-return-shorthand": true, 4 | "callable-types": true, 5 | "class-name": true, 6 | "comment-format": [ 7 | true, 8 | "check-space" 9 | ], 10 | "curly": true, 11 | "deprecation": { 12 | "severity": "warn" 13 | }, 14 | "eofline": true, 15 | "forin": true, 16 | "import-blacklist": [ 17 | true, 18 | "rxjs", 19 | "rxjs/Rx" 20 | ], 21 | "import-spacing": true, 22 | "indent": [ 23 | true, 24 | "spaces" 25 | ], 26 | "interface-over-type-literal": true, 27 | "label-position": true, 28 | "max-line-length": [ 29 | true, 30 | 140 31 | ], 32 | "member-access": false, 33 | "member-ordering": [ 34 | true, 35 | { 36 | "order": [ 37 | "static-field", 38 | "instance-field", 39 | "static-method", 40 | "instance-method" 41 | ] 42 | } 43 | ], 44 | "no-arg": true, 45 | "no-bitwise": true, 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-construct": true, 55 | "no-debugger": true, 56 | "no-duplicate-super": true, 57 | "no-empty": false, 58 | "no-empty-interface": true, 59 | "no-eval": true, 60 | "no-inferrable-types": false, 61 | "no-misused-new": true, 62 | "no-non-null-assertion": true, 63 | "no-shadowed-variable": true, 64 | "no-string-literal": false, 65 | "no-string-throw": true, 66 | "no-switch-case-fall-through": true, 67 | "no-trailing-whitespace": true, 68 | "no-unnecessary-initializer": true, 69 | "no-unused-expression": true, 70 | "no-use-before-declare": true, 71 | "no-var-keyword": true, 72 | "object-literal-sort-keys": false, 73 | "one-line": [ 74 | true, 75 | "check-open-brace", 76 | "check-catch", 77 | "check-else", 78 | "check-whitespace" 79 | ], 80 | "prefer-const": true, 81 | "quotemark": [ 82 | true, 83 | "single" 84 | ], 85 | "radix": true, 86 | "semicolon": [ 87 | true, 88 | "always" 89 | ], 90 | "triple-equals": [ 91 | true, 92 | "allow-null-check" 93 | ], 94 | "typedef-whitespace": [ 95 | true, 96 | { 97 | "call-signature": "nospace", 98 | "index-signature": "nospace", 99 | "parameter": "nospace", 100 | "property-declaration": "nospace", 101 | "variable-declaration": "nospace" 102 | } 103 | ], 104 | "unified-signatures": true, 105 | "variable-name": false, 106 | "whitespace": [ 107 | true, 108 | "check-branch", 109 | "check-decl", 110 | "check-operator", 111 | "check-separator", 112 | "check-type" 113 | ], 114 | "directive-selector": [ 115 | true, 116 | "attribute", 117 | "app", 118 | "camelCase" 119 | ], 120 | "component-selector": [ 121 | true, 122 | "element", 123 | "app", 124 | "kebab-case" 125 | ], 126 | "no-output-on-prefix": true, 127 | "use-input-property-decorator": true, 128 | "use-output-property-decorator": true, 129 | "use-host-property-decorator": true, 130 | "no-input-rename": true, 131 | "no-output-rename": true, 132 | "use-life-cycle-interface": true, 133 | "use-pipe-transform-interface": true, 134 | "component-class-suffix": true, 135 | "directive-class-suffix": true 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events.json 15 | speed-measure-plugin.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Mempool Space 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.1.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "mempoolspace": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/mempoolspace", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "aot": true, 26 | "assets": [ 27 | "src/favicon.ico", 28 | "src/assets" 29 | ], 30 | "styles": [ 31 | "src/styles.scss" 32 | ], 33 | "scripts": [] 34 | }, 35 | "configurations": { 36 | "production": { 37 | "fileReplacements": [ 38 | { 39 | "replace": "src/environments/environment.ts", 40 | "with": "src/environments/environment.prod.ts" 41 | } 42 | ], 43 | "optimization": true, 44 | "outputHashing": "all", 45 | "sourceMap": false, 46 | "extractCss": true, 47 | "namedChunks": false, 48 | "extractLicenses": true, 49 | "vendorChunk": false, 50 | "buildOptimizer": true, 51 | "budgets": [ 52 | { 53 | "type": "initial", 54 | "maximumWarning": "2mb", 55 | "maximumError": "5mb" 56 | }, 57 | { 58 | "type": "anyComponentStyle", 59 | "maximumWarning": "6kb" 60 | } 61 | ] 62 | } 63 | } 64 | }, 65 | "serve": { 66 | "builder": "@angular-devkit/build-angular:dev-server", 67 | "options": { 68 | "browserTarget": "mempoolspace:build" 69 | }, 70 | "configurations": { 71 | "production": { 72 | "browserTarget": "mempoolspace:build:production" 73 | } 74 | } 75 | }, 76 | "extract-i18n": { 77 | "builder": "@angular-devkit/build-angular:extract-i18n", 78 | "options": { 79 | "browserTarget": "mempoolspace:build" 80 | } 81 | }, 82 | "test": { 83 | "builder": "@angular-devkit/build-angular:karma", 84 | "options": { 85 | "main": "src/test.ts", 86 | "polyfills": "src/polyfills.ts", 87 | "tsConfig": "tsconfig.spec.json", 88 | "karmaConfig": "karma.conf.js", 89 | "assets": [ 90 | "src/favicon.ico", 91 | "src/assets" 92 | ], 93 | "styles": [ 94 | "src/styles.scss" 95 | ], 96 | "scripts": [] 97 | } 98 | }, 99 | "lint": { 100 | "builder": "@angular-devkit/build-angular:tslint", 101 | "options": { 102 | "tsConfig": [ 103 | "tsconfig.app.json", 104 | "tsconfig.spec.json", 105 | "e2e/tsconfig.json" 106 | ], 107 | "exclude": [ 108 | "**/node_modules/**" 109 | ] 110 | } 111 | }, 112 | "e2e": { 113 | "builder": "@angular-devkit/build-angular:protractor", 114 | "options": { 115 | "protractorConfig": "e2e/protractor.conf.js", 116 | "devServerTarget": "mempoolspace:serve" 117 | }, 118 | "configurations": { 119 | "production": { 120 | "devServerTarget": "mempoolspace:serve:production" 121 | } 122 | } 123 | } 124 | } 125 | }}, 126 | "defaultProject": "mempoolspace" 127 | } -------------------------------------------------------------------------------- /frontend/browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /frontend/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | 'browserName': 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /frontend/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('Welcome to mempoolspace!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root h1')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/mempoolspace'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mempoolspace", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --proxy-config proxy.conf.json", 7 | "build": "ng build --prod", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~9.0.0", 15 | "@angular/common": "~9.0.0", 16 | "@angular/compiler": "~9.0.0", 17 | "@angular/core": "~9.0.0", 18 | "@angular/forms": "~9.0.0", 19 | "@angular/localize": "^9.0.1", 20 | "@angular/platform-browser": "~9.0.0", 21 | "@angular/platform-browser-dynamic": "~9.0.0", 22 | "@angular/router": "~9.0.0", 23 | "@ng-bootstrap/ng-bootstrap": "^5.3.0", 24 | "@types/qrcode": "^1.3.4", 25 | "bootstrap": "^4.4.1", 26 | "chartist": "^0.11.4", 27 | "clipboard": "^2.0.4", 28 | "qrcode": "^1.4.4", 29 | "rxjs": "~6.5.3", 30 | "tlite": "^0.1.9", 31 | "tslib": "^1.10.0", 32 | "zone.js": "~0.10.2" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "~0.900.1", 36 | "@angular/cli": "~9.0.1", 37 | "@angular/compiler-cli": "~9.0.0", 38 | "@angular/language-service": "~9.0.0", 39 | "@types/jasmine": "~3.3.8", 40 | "@types/jasminewd2": "~2.0.3", 41 | "@types/node": "^12.11.1", 42 | "codelyzer": "^5.1.2", 43 | "jasmine-core": "~3.4.0", 44 | "jasmine-spec-reporter": "~4.2.1", 45 | "karma": "~4.1.0", 46 | "karma-chrome-launcher": "~2.2.0", 47 | "karma-coverage-istanbul-reporter": "~2.0.1", 48 | "karma-jasmine": "~2.0.1", 49 | "karma-jasmine-html-reporter": "^1.4.0", 50 | "protractor": "~5.4.0", 51 | "ts-node": "~7.0.0", 52 | "tslint": "~5.15.0", 53 | "typescript": "~3.6.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:8999/", 4 | "secure": false 5 | } 6 | } -------------------------------------------------------------------------------- /frontend/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { StartComponent } from './components/start/start.component'; 4 | import { TransactionComponent } from './components/transaction/transaction.component'; 5 | import { BlockComponent } from './components/block/block.component'; 6 | import { AddressComponent } from './components/address/address.component'; 7 | import { MasterPageComponent } from './components/master-page/master-page.component'; 8 | import { AboutComponent } from './components/about/about.component'; 9 | import { TelevisionComponent } from './components/television/television.component'; 10 | import { StatisticsComponent } from './components/statistics/statistics.component'; 11 | import { ExplorerComponent } from './components/explorer/explorer.component'; 12 | 13 | const routes: Routes = [ 14 | { 15 | path: '', 16 | component: MasterPageComponent, 17 | children: [ 18 | { 19 | path: '', 20 | component: StartComponent, 21 | }, 22 | { 23 | path: 'explorer', 24 | component: ExplorerComponent, 25 | }, 26 | { 27 | path: 'graphs', 28 | component: StatisticsComponent, 29 | }, 30 | { 31 | path: 'about', 32 | component: AboutComponent, 33 | }, 34 | { 35 | path: 'tx/:id', 36 | children: [], 37 | component: TransactionComponent 38 | }, 39 | { 40 | path: 'block/:id', 41 | children: [], 42 | component: BlockComponent 43 | }, 44 | { 45 | path: 'address/:id', 46 | children: [], 47 | component: AddressComponent 48 | }, 49 | ], 50 | }, 51 | { 52 | path: 'tv', 53 | component: TelevisionComponent, 54 | }, 55 | { 56 | path: '**', 57 | redirectTo: '' 58 | } 59 | ]; 60 | 61 | @NgModule({ 62 | imports: [RouterModule.forRoot(routes)], 63 | exports: [RouterModule] 64 | }) 65 | export class AppRoutingModule { } 66 | -------------------------------------------------------------------------------- /frontend/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 6 | import { NgbButtonsModule } from '@ng-bootstrap/ng-bootstrap'; 7 | 8 | import { AppRoutingModule } from './app-routing.module'; 9 | import { AppComponent } from './components/app/app.component'; 10 | 11 | import { StartComponent } from './components/start/start.component'; 12 | import { ElectrsApiService } from './services/electrs-api.service'; 13 | import { TimeSincePipe } from './pipes/time-since/time-since.pipe'; 14 | import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe'; 15 | import { VbytesPipe } from './pipes/bytes-pipe/vbytes.pipe'; 16 | import { WuBytesPipe } from './pipes/bytes-pipe/wubytes.pipe'; 17 | import { TransactionComponent } from './components/transaction/transaction.component'; 18 | import { TransactionsListComponent } from './components/transactions-list/transactions-list.component'; 19 | import { AmountComponent } from './components/amount/amount.component'; 20 | import { StateService } from './services/state.service'; 21 | import { BlockComponent } from './components/block/block.component'; 22 | import { ShortenStringPipe } from './pipes/shorten-string-pipe/shorten-string.pipe'; 23 | import { AddressComponent } from './components/address/address.component'; 24 | import { SearchFormComponent } from './components/search-form/search-form.component'; 25 | import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component'; 26 | import { WebsocketService } from './services/websocket.service'; 27 | import { TimeSinceComponent } from './components/time-since/time-since.component'; 28 | import { AddressLabelsComponent } from './components/address-labels/address-labels.component'; 29 | import { MempoolBlocksComponent } from './components/mempool-blocks/mempool-blocks.component'; 30 | import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe'; 31 | import { LatestTransactionsComponent } from './components/latest-transactions/latest-transactions.component'; 32 | import { QrcodeComponent } from './components/qrcode/qrcode.component'; 33 | import { ClipboardComponent } from './components/clipboard/clipboard.component'; 34 | import { MasterPageComponent } from './components/master-page/master-page.component'; 35 | import { AboutComponent } from './components/about/about.component'; 36 | import { TelevisionComponent } from './components/television/television.component'; 37 | import { StatisticsComponent } from './components/statistics/statistics.component'; 38 | import { ChartistComponent } from './components/statistics/chartist.component'; 39 | import { BlockchainBlocksComponent } from './components/blockchain-blocks/blockchain-blocks.component'; 40 | import { BlockchainComponent } from './components/blockchain/blockchain.component'; 41 | import { FooterComponent } from './components/footer/footer.component'; 42 | import { ExplorerComponent } from './components/explorer/explorer.component'; 43 | 44 | @NgModule({ 45 | declarations: [ 46 | AppComponent, 47 | AboutComponent, 48 | MasterPageComponent, 49 | TelevisionComponent, 50 | BlockchainComponent, 51 | StartComponent, 52 | BlockchainBlocksComponent, 53 | StatisticsComponent, 54 | TransactionComponent, 55 | BlockComponent, 56 | TransactionsListComponent, 57 | TimeSincePipe, 58 | BytesPipe, 59 | VbytesPipe, 60 | WuBytesPipe, 61 | CeilPipe, 62 | ShortenStringPipe, 63 | AddressComponent, 64 | AmountComponent, 65 | SearchFormComponent, 66 | LatestBlocksComponent, 67 | TimeSinceComponent, 68 | AddressLabelsComponent, 69 | MempoolBlocksComponent, 70 | LatestTransactionsComponent, 71 | QrcodeComponent, 72 | ClipboardComponent, 73 | ExplorerComponent, 74 | ChartistComponent, 75 | FooterComponent, 76 | ], 77 | imports: [ 78 | BrowserModule, 79 | AppRoutingModule, 80 | HttpClientModule, 81 | ReactiveFormsModule, 82 | BrowserAnimationsModule, 83 | NgbButtonsModule, 84 | ], 85 | providers: [ 86 | ElectrsApiService, 87 | StateService, 88 | WebsocketService, 89 | VbytesPipe, 90 | ], 91 | bootstrap: [AppComponent] 92 | }) 93 | export class AppModule { } 94 | -------------------------------------------------------------------------------- /frontend/src/app/components/about/about.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

5 | 6 |

About

7 | 8 |

Mempool.Space is a realtime Bitcoin blockchain explorer and mempool visualizer.

9 |

Created by @softbtc 10 |
Hosted by @wiz 11 |
Designed by @markjborg 12 |

13 | 14 |

HTTP API

15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 32 | 33 |
Fee API 20 |
21 | 22 |
23 |
Mempool blocks 28 |
29 | 30 |
31 |
34 | 35 |

WebSocket API

36 | 37 | 38 | 39 | 45 | 50 | 51 |
40 | 41 | Upon connection, send object {{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }} 42 | to express what you want pushed. Available: 'blocks', 'mempool-blocks', 'live-2h-chart' and 'stats'. 43 | 44 | 46 |
47 | 48 |
49 |
52 | 53 | 54 |
55 | -------------------------------------------------------------------------------- /frontend/src/app/components/about/about.component.scss: -------------------------------------------------------------------------------- 1 | .text-small { 2 | font-size: 12px; 3 | } 4 | 5 | .code { 6 | background-color: #1d1f31; 7 | font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New; 8 | } -------------------------------------------------------------------------------- /frontend/src/app/components/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { WebsocketService } from '../../services/websocket.service'; 3 | 4 | @Component({ 5 | selector: 'app-about', 6 | templateUrl: './about.component.html', 7 | styleUrls: ['./about.component.scss'] 8 | }) 9 | export class AboutComponent implements OnInit { 10 | 11 | constructor( 12 | private websocketService: WebsocketService, 13 | ) { } 14 | 15 | ngOnInit() { 16 | this.websocketService.want(['blocks']); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/components/address-labels/address-labels.component.html: -------------------------------------------------------------------------------- 1 | multisig {{ multisigM }} of {{ multisigN }} 2 | -------------------------------------------------------------------------------- /frontend/src/app/components/address-labels/address-labels.component.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | margin-right: 2px; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/components/address-labels/address-labels.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AddressLabelsComponent } from './address-labels.component'; 4 | 5 | describe('AddressLabelsComponent', () => { 6 | let component: AddressLabelsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AddressLabelsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AddressLabelsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/address-labels/address-labels.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | import { Vin, Vout } from '../../interfaces/electrs.interface'; 3 | 4 | @Component({ 5 | selector: 'app-address-labels', 6 | templateUrl: './address-labels.component.html', 7 | styleUrls: ['./address-labels.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class AddressLabelsComponent implements OnInit { 11 | 12 | @Input() vin: Vin; 13 | @Input() vout: Vout; 14 | 15 | multisig = false; 16 | multisigM: number; 17 | multisigN: number; 18 | 19 | constructor() { } 20 | 21 | ngOnInit() { 22 | if (this.vin) { 23 | this.handleVin(); 24 | } else if (this.vout) { 25 | this.handleVout(); 26 | } 27 | } 28 | 29 | handleVin() { 30 | if (this.vin.inner_witnessscript_asm && this.vin.inner_witnessscript_asm.indexOf('OP_CHECKMULTISIG') > -1) { 31 | const matches = this.getMatches(this.vin.inner_witnessscript_asm, /OP_PUSHNUM_([0-9])/g, 1); 32 | this.multisig = true; 33 | this.multisigM = matches[0]; 34 | this.multisigN = matches[1]; 35 | } 36 | 37 | if (this.vin.inner_redeemscript_asm && this.vin.inner_redeemscript_asm.indexOf('OP_CHECKMULTISIG') > -1) { 38 | const matches = this.getMatches(this.vin.inner_redeemscript_asm, /OP_PUSHNUM_([0-9])/g, 1); 39 | this.multisig = true; 40 | this.multisigM = matches[0]; 41 | this.multisigN = matches[1]; 42 | } 43 | } 44 | 45 | handleVout() { 46 | } 47 | 48 | getMatches(str: string, regex: RegExp, index: number) { 49 | if (!index) { 50 | index = 1; 51 | } 52 | const matches = []; 53 | let match; 54 | while (match = regex.exec(str)) { 55 | matches.push(match[index]); 56 | } 57 | return matches; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/app/components/address/address.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Address

3 | {{ addressString }} 4 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
Number of transactions{{ address.chain_stats.tx_count + address.mempool_stats.tx_count }}
Total received{{ (address.chain_stats.funded_txo_sum + address.mempool_stats.funded_txo_sum) / 100000000 | number: '1.2-2' }} BTC
Total sent{{ (address.chain_stats.spent_txo_sum + address.mempool_stats.spent_txo_sum) / 100000000 | number: '1.2-2' }} BTC
30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 |

{{ transactions?.length || '?' }} of {{ address.chain_stats.tx_count + address.mempool_stats.tx_count + addedTransactions }} transactions

44 | 45 | 46 | 47 |
48 | 49 |
50 |

51 |
52 | 53 |
54 | 55 |
56 | 57 | 58 | 59 |
60 |
61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
75 |
76 |
77 | 78 |
79 |
80 |
81 | 82 |
83 | 84 |
85 |
86 |

87 |
88 |
89 | 90 | 91 |
92 | Error loading address data. 93 |
94 | {{ error.error }} 95 |
96 |
97 | 98 |
99 | 100 |
-------------------------------------------------------------------------------- /frontend/src/app/components/address/address.component.scss: -------------------------------------------------------------------------------- 1 | .header-bg { 2 | font-size: 14px; 3 | } 4 | 5 | .qr-wrapper { 6 | background-color: #FFF; 7 | padding: 10px; 8 | padding-bottom: 5px; 9 | display: inline-block; 10 | margin-right: 25px; 11 | } -------------------------------------------------------------------------------- /frontend/src/app/components/address/address.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { ActivatedRoute, ParamMap } from '@angular/router'; 3 | import { ElectrsApiService } from '../../services/electrs-api.service'; 4 | import { switchMap } from 'rxjs/operators'; 5 | import { Address, Transaction } from '../../interfaces/electrs.interface'; 6 | import { WebsocketService } from 'src/app/services/websocket.service'; 7 | import { StateService } from 'src/app/services/state.service'; 8 | 9 | @Component({ 10 | selector: 'app-address', 11 | templateUrl: './address.component.html', 12 | styleUrls: ['./address.component.scss'] 13 | }) 14 | export class AddressComponent implements OnInit, OnDestroy { 15 | address: Address; 16 | addressString: string; 17 | isLoadingAddress = true; 18 | transactions: Transaction[]; 19 | isLoadingTransactions = true; 20 | error: any; 21 | addedTransactions = 0; 22 | 23 | constructor( 24 | private route: ActivatedRoute, 25 | private electrsApiService: ElectrsApiService, 26 | private websocketService: WebsocketService, 27 | private stateService: StateService, 28 | ) { } 29 | 30 | ngOnInit() { 31 | this.websocketService.want(['blocks', 'mempool-blocks']); 32 | 33 | this.route.paramMap.pipe( 34 | switchMap((params: ParamMap) => { 35 | this.error = undefined; 36 | this.isLoadingAddress = true; 37 | this.isLoadingTransactions = true; 38 | this.transactions = null; 39 | this.addressString = params.get('id') || ''; 40 | return this.electrsApiService.getAddress$(this.addressString); 41 | }) 42 | ) 43 | .subscribe((address) => { 44 | this.address = address; 45 | this.websocketService.startTrackAddress(address.address); 46 | this.isLoadingAddress = false; 47 | document.body.scrollTo({ top: 0, behavior: 'smooth' }); 48 | this.getAddressTransactions(address.address); 49 | }, 50 | (error) => { 51 | console.log(error); 52 | this.error = error; 53 | this.isLoadingAddress = false; 54 | }); 55 | 56 | this.stateService.mempoolTransactions$ 57 | .subscribe((transaction) => { 58 | this.transactions.unshift(transaction); 59 | this.addedTransactions++; 60 | }); 61 | 62 | this.stateService.blockTransactions$ 63 | .subscribe((transaction) => { 64 | const tx = this.transactions.find((t) => t.txid === transaction.txid); 65 | if (tx) { 66 | tx.status = transaction.status; 67 | } 68 | }); 69 | 70 | this.stateService.isOffline$ 71 | .subscribe((state) => { 72 | if (!state && this.transactions && this.transactions.length) { 73 | this.isLoadingTransactions = true; 74 | this.getAddressTransactions(this.address.address); 75 | } 76 | }); 77 | } 78 | 79 | getAddressTransactions(address: string) { 80 | this.electrsApiService.getAddressTransactions$(address) 81 | .subscribe((transactions: any) => { 82 | this.transactions = transactions; 83 | this.isLoadingTransactions = false; 84 | }); 85 | } 86 | 87 | loadMore() { 88 | this.isLoadingTransactions = true; 89 | this.electrsApiService.getAddressTransactionsFromHash$(this.address.address, this.transactions[this.transactions.length - 1].txid) 90 | .subscribe((transactions) => { 91 | this.transactions = this.transactions.concat(transactions); 92 | this.isLoadingTransactions = false; 93 | }); 94 | } 95 | 96 | ngOnDestroy() { 97 | this.websocketService.startTrackAddress('stop'); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /frontend/src/app/components/amount/amount.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ conversions.USD * (satoshis / 100000000) | currency:'USD':'symbol':'1.2-2' }} 3 | 4 | 5 | {{ satoshis / 100000000 }} BTC 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/app/components/amount/amount.component.scss: -------------------------------------------------------------------------------- 1 | .green-color { 2 | color: #3bcc49; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/components/amount/amount.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AmountComponent } from './amount.component'; 4 | 5 | describe('AmountComponent', () => { 6 | let component: AmountComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AmountComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AmountComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/amount/amount.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core'; 2 | import { StateService } from '../../services/state.service'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Component({ 6 | selector: 'app-amount', 7 | templateUrl: './amount.component.html', 8 | styleUrls: ['./amount.component.scss'], 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | }) 11 | export class AmountComponent implements OnInit { 12 | conversions$: Observable; 13 | viewFiat$: Observable; 14 | 15 | @Input() satoshis: number; 16 | 17 | constructor( 18 | private stateService: StateService, 19 | ) { } 20 | 21 | ngOnInit() { 22 | this.viewFiat$ = this.stateService.viewFiat$.asObservable(); 23 | this.conversions$ = this.stateService.conversions$.asObservable(); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/app/components/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/app/components/app/app.component.scss: -------------------------------------------------------------------------------- 1 | footer { 2 | max-width: 960px; 3 | } 4 | 5 | .logo { 6 | height: 40px; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/components/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async(() => { 7 | TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule 10 | ], 11 | declarations: [ 12 | AppComponent 13 | ], 14 | }).compileComponents(); 15 | })); 16 | 17 | it('should create the app', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.debugElement.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | 23 | it(`should have as title 'mempoolspace'`, () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | const app = fixture.debugElement.componentInstance; 26 | expect(app.title).toEqual('mempoolspace'); 27 | }); 28 | 29 | it('should render title in a h1 tag', () => { 30 | const fixture = TestBed.createComponent(AppComponent); 31 | fixture.detectChanges(); 32 | const compiled = fixture.debugElement.nativeElement; 33 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to mempoolspace!'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /frontend/src/app/components/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { WebsocketService } from '../../services/websocket.service'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | templateUrl: './app.component.html', 8 | styleUrls: ['./app.component.scss'] 9 | }) 10 | export class AppComponent { 11 | constructor( 12 | public router: Router, 13 | private websocketService: WebsocketService, 14 | ) { } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/app/components/block/block.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |

Block #{{ blockHeight }}

7 |
8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
Timestamp{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} ( ago)
Number of transactions{{ block.tx_count }}
Size{{ block.size | bytes: 2 }}
Weight{{ block.weight | wuBytes: 2 }}
Status
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
Hash{{ block.id | shortenString : 32 }}
Previous Block{{ block.previousblockhash | shortenString : 32 }}
54 |
55 |
56 |
57 | 58 |
59 | 60 |

{{ transactions?.length || '?' }} of {{ block.tx_count }} transactions

61 | 62 |
63 | 64 | 65 | 66 |
67 | 68 |
69 |

70 |
71 | 72 |
73 | 74 |
75 | 76 | 77 | 78 |
79 | 80 |
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |
103 |
104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
114 |
115 |
116 |
117 | 118 |
119 | 120 |
121 |
122 |

123 |
124 |
125 | 126 | 127 |
128 | Error loading block data. 129 |
130 | {{ error.error }} 131 |
132 |
133 | 134 |
135 | 136 |
-------------------------------------------------------------------------------- /frontend/src/app/components/block/block.component.scss: -------------------------------------------------------------------------------- 1 | .title-block { 2 | color: #FFF; 3 | padding-left: 10px; 4 | padding-top: 20px; 5 | padding-bottom: 3px; 6 | border-top: 5px solid #FFF; 7 | } 8 | 9 | .title-block > h1 { 10 | margin: 0; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/app/components/block/block.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, ParamMap } from '@angular/router'; 3 | import { ElectrsApiService } from '../../services/electrs-api.service'; 4 | import { switchMap } from 'rxjs/operators'; 5 | import { Block, Transaction } from '../../interfaces/electrs.interface'; 6 | import { of } from 'rxjs'; 7 | import { StateService } from '../../services/state.service'; 8 | import { WebsocketService } from 'src/app/services/websocket.service'; 9 | 10 | @Component({ 11 | selector: 'app-block', 12 | templateUrl: './block.component.html', 13 | styleUrls: ['./block.component.scss'] 14 | }) 15 | export class BlockComponent implements OnInit { 16 | block: Block; 17 | blockHeight: number; 18 | blockHash: string; 19 | isLoadingBlock = true; 20 | latestBlock: Block; 21 | transactions: Transaction[]; 22 | isLoadingTransactions = true; 23 | error: any; 24 | 25 | constructor( 26 | private route: ActivatedRoute, 27 | private electrsApiService: ElectrsApiService, 28 | private stateService: StateService, 29 | private websocketService: WebsocketService, 30 | ) { } 31 | 32 | ngOnInit() { 33 | this.websocketService.want(['blocks', 'mempool-blocks']); 34 | 35 | this.route.paramMap.pipe( 36 | switchMap((params: ParamMap) => { 37 | const blockHash: string = params.get('id') || ''; 38 | this.error = undefined; 39 | 40 | if (history.state.data && history.state.data.blockHeight) { 41 | this.blockHeight = history.state.data.blockHeight; 42 | } 43 | 44 | this.blockHash = blockHash; 45 | 46 | if (history.state.data && history.state.data.block) { 47 | this.blockHeight = history.state.data.block.height; 48 | return of(history.state.data.block); 49 | } else { 50 | this.isLoadingBlock = true; 51 | return this.electrsApiService.getBlock$(blockHash); 52 | } 53 | }) 54 | ) 55 | .subscribe((block: Block) => { 56 | this.block = block; 57 | this.blockHeight = block.height; 58 | this.isLoadingBlock = false; 59 | this.getBlockTransactions(block.id); 60 | document.body.scrollTo({ top: 0, behavior: 'smooth' }); 61 | }, 62 | (error) => { 63 | this.error = error; 64 | this.isLoadingBlock = false; 65 | }); 66 | 67 | this.stateService.blocks$ 68 | .subscribe((block) => this.latestBlock = block); 69 | } 70 | 71 | getBlockTransactions(hash: string) { 72 | this.electrsApiService.getBlockTransactions$(hash) 73 | .subscribe((transactions: any) => { 74 | this.transactions = transactions; 75 | this.isLoadingTransactions = false; 76 | }); 77 | } 78 | 79 | loadMore() { 80 | this.isLoadingTransactions = true; 81 | this.electrsApiService.getBlockTransactions$(this.block.id, this.transactions.length) 82 | .subscribe((transactions) => { 83 | this.transactions = this.transactions.concat(transactions); 84 | this.isLoadingTransactions = false; 85 | }); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |   5 | 8 |
9 |
10 | ~{{ block.medianFee | ceil }} sat/vB 11 |
12 | {{ block.feeRange[0] | ceil }} - {{ block.feeRange[block.feeRange.length - 1] | ceil }} sat/vB 13 |
14 |
{{ block.size | bytes: 2 }}
15 |
{{ block.tx_count }} transactions
16 |

17 |
{{ block.timestamp | timeSince : trigger }} ago
18 |
19 |
20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss: -------------------------------------------------------------------------------- 1 | .bitcoin-block { 2 | width: 125px; 3 | height: 125px; 4 | } 5 | 6 | .blockLink { 7 | width: 100%; 8 | height: 100%; 9 | position: absolute; 10 | left: 0; 11 | z-index: 10; 12 | } 13 | 14 | .mined-block { 15 | position: absolute; 16 | top: 0px; 17 | transition: 1s; 18 | } 19 | 20 | .block-size { 21 | font-size: 18px; 22 | font-weight: bold; 23 | } 24 | 25 | .blocks-container { 26 | position: absolute; 27 | top: 0px; 28 | left: 40px; 29 | } 30 | 31 | .block-body { 32 | text-align: center; 33 | } 34 | 35 | .time-difference { 36 | position: absolute; 37 | bottom: 10px; 38 | text-align: center; 39 | width: 100%; 40 | font-size: 14px; 41 | } 42 | 43 | .fees { 44 | font-size: 10px; 45 | margin-top: 10px; 46 | margin-bottom: 2px; 47 | } 48 | 49 | .transaction-count { 50 | font-size: 12px; 51 | } 52 | 53 | .block-height { 54 | position: absolute; 55 | font-size: 12px; 56 | bottom: 160px; 57 | width: 100%; 58 | left: -12px; 59 | text-shadow: 0px 32px 3px #111; 60 | z-index: 100; 61 | } 62 | 63 | @media (max-width: 767.98px) { 64 | .block-height { 65 | bottom: 125px; 66 | left: inherit; 67 | text-shadow: inherit; 68 | z-index: inherit; 69 | } 70 | } 71 | 72 | @media (min-width: 768px) { 73 | .bitcoin-block::after { 74 | content: ''; 75 | width: 125px; 76 | height: 24px; 77 | position:absolute; 78 | top: -24px; 79 | left: -20px; 80 | background-color: #232838; 81 | transform:skew(40deg); 82 | transform-origin:top; 83 | } 84 | 85 | .bitcoin-block::before { 86 | content: ''; 87 | width: 20px; 88 | height: 125px; 89 | position: absolute; 90 | top: -12px; 91 | left: -20px; 92 | background-color: #191c27; 93 | 94 | transform: skewY(50deg); 95 | transform-origin: top; 96 | } 97 | } 98 | 99 | .black-background { 100 | background-color: #11131f; 101 | z-index: 100; 102 | position: relative; 103 | } 104 | 105 | #arrow-up { 106 | position: relative; 107 | left: 30px; 108 | top: 140px; 109 | transition: 1s; 110 | width: 0; 111 | height: 0; 112 | border-left: 35px solid transparent; 113 | border-right: 35px solid transparent; 114 | border-bottom: 35px solid #FFF; 115 | } -------------------------------------------------------------------------------- /frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, Input, OnChanges } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | import { Block } from 'src/app/interfaces/electrs.interface'; 4 | import { StateService } from 'src/app/services/state.service'; 5 | 6 | @Component({ 7 | selector: 'app-blockchain-blocks', 8 | templateUrl: './blockchain-blocks.component.html', 9 | styleUrls: ['./blockchain-blocks.component.scss'] 10 | }) 11 | export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { 12 | @Input() markHeight = 0; 13 | 14 | blocks: Block[] = []; 15 | blocksSubscription: Subscription; 16 | interval: any; 17 | trigger = 0; 18 | 19 | 20 | arrowVisible = false; 21 | arrowLeftPx = 30; 22 | 23 | constructor( 24 | private stateService: StateService, 25 | ) { } 26 | 27 | ngOnInit() { 28 | this.blocksSubscription = this.stateService.blocks$ 29 | .subscribe((block) => { 30 | if (this.blocks.some((b) => b.height === block.height)) { 31 | return; 32 | } 33 | this.blocks.unshift(block); 34 | this.blocks = this.blocks.slice(0, 8); 35 | 36 | this.moveArrowToPosition(); 37 | }); 38 | 39 | this.interval = setInterval(() => this.trigger++, 10 * 1000); 40 | } 41 | 42 | ngOnChanges() { 43 | this.moveArrowToPosition(); 44 | } 45 | 46 | ngOnDestroy() { 47 | this.blocksSubscription.unsubscribe(); 48 | clearInterval(this.interval); 49 | } 50 | 51 | moveArrowToPosition() { 52 | if (!this.markHeight) { 53 | this.arrowVisible = false; 54 | return; 55 | } 56 | const blockindex = this.blocks.findIndex((b) => b.height === this.markHeight); 57 | if (blockindex !== -1) { 58 | this.arrowVisible = true; 59 | this.arrowLeftPx = blockindex * 155 + 30; 60 | } 61 | } 62 | 63 | trackByBlocksFn(index: number, item: Block) { 64 | return item.height; 65 | } 66 | 67 | getStyleForBlock(block: Block) { 68 | const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100; 69 | if (window.innerWidth <= 768) { 70 | return { 71 | top: 155 * this.blocks.indexOf(block) + 'px', 72 | background: `repeating-linear-gradient(to right, #2d3348, #2d3348 ${greenBackgroundHeight}%, 73 | #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, 74 | }; 75 | } else { 76 | return { 77 | left: 155 * this.blocks.indexOf(block) + 'px', 78 | background: `repeating-linear-gradient(to right, #2d3348, #2d3348 ${greenBackgroundHeight}%, 79 | #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, 80 | }; 81 | } 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/app/components/blockchain/blockchain.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 |
7 | 8 | 9 |
10 |

Waiting for blocks...

11 |
12 |
13 |
14 |
15 | 16 |
17 |
-------------------------------------------------------------------------------- /frontend/src/app/components/blockchain/blockchain.component.scss: -------------------------------------------------------------------------------- 1 | #divider { 2 | width: 3px; 3 | height: 200px; 4 | left: 0; 5 | top: -50px; 6 | background-image: url('/assets/divider-new.png'); 7 | background-repeat: repeat-y; 8 | position: absolute; 9 | margin-bottom: 120px; 10 | } 11 | 12 | #divider > img { 13 | position: absolute; 14 | left: -100px; 15 | top: -28px; 16 | } 17 | 18 | .blockchain-wrapper { 19 | overflow: hidden; 20 | height: 250px; 21 | } 22 | 23 | .position-container { 24 | position: absolute; 25 | left: 50%; 26 | } 27 | 28 | @media (max-width: 767.98px) { 29 | #divider { 30 | top: -50px; 31 | height: 1300px; 32 | } 33 | .position-container { 34 | top: 100px; 35 | } 36 | } 37 | 38 | @media (min-width: 1920px) { 39 | .position-container { 40 | transform: scale(1.3); 41 | } 42 | } 43 | 44 | .black-background { 45 | background-color: #11131f; 46 | z-index: 100; 47 | position: relative; 48 | } 49 | 50 | .loading-block { 51 | position: absolute; 52 | text-align: center; 53 | margin: auto; 54 | width: 300px; 55 | left: -150px; 56 | top: 0px; 57 | } -------------------------------------------------------------------------------- /frontend/src/app/components/blockchain/blockchain.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, Input } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | import { take } from 'rxjs/operators'; 4 | import { StateService } from 'src/app/services/state.service'; 5 | 6 | @Component({ 7 | selector: 'app-blockchain', 8 | templateUrl: './blockchain.component.html', 9 | styleUrls: ['./blockchain.component.scss'] 10 | }) 11 | export class BlockchainComponent implements OnInit, OnDestroy { 12 | @Input() position: 'middle' | 'top' = 'middle'; 13 | @Input() markHeight: number; 14 | @Input() txFeePerVSize: number; 15 | 16 | txTrackingSubscription: Subscription; 17 | blocksSubscription: Subscription; 18 | 19 | txTrackingLoading = false; 20 | txShowTxNotFound = false; 21 | isLoading = true; 22 | 23 | constructor( 24 | private stateService: StateService, 25 | ) {} 26 | 27 | ngOnInit() { 28 | this.blocksSubscription = this.stateService.blocks$ 29 | .pipe( 30 | take(1) 31 | ) 32 | .subscribe((block) => this.isLoading = false); 33 | } 34 | 35 | ngOnDestroy() { 36 | this.blocksSubscription.unsubscribe(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/app/components/clipboard/clipboard.component.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/components/clipboard/clipboard.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/app/components/clipboard/clipboard.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/clipboard/clipboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ClipboardComponent } from './clipboard.component'; 4 | 5 | describe('ClipboardComponent', () => { 6 | let component: ClipboardComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ClipboardComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ClipboardComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/clipboard/clipboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, Input } from '@angular/core'; 2 | import * as ClipboardJS from 'clipboard'; 3 | import * as tlite from 'tlite'; 4 | 5 | @Component({ 6 | selector: 'app-clipboard', 7 | templateUrl: './clipboard.component.html', 8 | styleUrls: ['./clipboard.component.scss'] 9 | }) 10 | export class ClipboardComponent implements AfterViewInit { 11 | @ViewChild('btn') btn: ElementRef; 12 | @ViewChild('buttonWrapper') buttonWrapper: ElementRef; 13 | @Input() text: string; 14 | 15 | clipboard: any; 16 | 17 | constructor() { } 18 | 19 | ngAfterViewInit() { 20 | this.clipboard = new ClipboardJS(this.btn.nativeElement); 21 | this.clipboard.on('success', (e) => { 22 | tlite.show(this.buttonWrapper.nativeElement); 23 | setTimeout(() => { 24 | tlite.hide(this.buttonWrapper.nativeElement); 25 | }, 1000); 26 | }); 27 | } 28 | 29 | onDestroy() { 30 | this.clipboard.destroy(); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app/components/explorer/explorer.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /frontend/src/app/components/explorer/explorer.component.scss: -------------------------------------------------------------------------------- 1 | .search-container { 2 | padding-top: 50px; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/components/explorer/explorer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExplorerComponent } from './explorer.component'; 4 | 5 | describe('ExplorerComponent', () => { 6 | let component: ExplorerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ExplorerComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ExplorerComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/explorer/explorer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-explorer', 6 | templateUrl: './explorer.component.html', 7 | styleUrls: ['./explorer.component.scss'] 8 | }) 9 | export class ExplorerComponent implements OnInit { 10 | view: 'blocks' | 'transactions' = 'blocks'; 11 | 12 | constructor( 13 | private route: ActivatedRoute, 14 | ) {} 15 | 16 | ngOnInit() { 17 | this.route.fragment 18 | .subscribe((fragment: string) => { 19 | if (fragment === 'transactions' ) { 20 | this.view = 'transactions'; 21 | } 22 | }); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | Unconfirmed transactions: {{ memPoolInfo?.memPoolInfo?.size | number }} 6 |
7 | Mempool size: {{ mempoolSize | bytes }} ({{ mempoolBlocks }} blocks) 8 |
9 | Tx weight per second:  10 | 11 |
12 |
{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s
13 |
14 | 15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /frontend/src/app/components/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | position: fixed; 3 | bottom: 0; 4 | width: 100%; 5 | height: 120px; 6 | background-color: #1d1f31; 7 | } 8 | 9 | .footer > .container { 10 | margin-top: 25px; 11 | } 12 | 13 | .txPerSecond { 14 | color: #4a9ff4; 15 | } 16 | 17 | .mempoolSize { 18 | color: #4a68b9; 19 | } 20 | 21 | .unconfirmedTx { 22 | color: #f14d80; 23 | } 24 | 25 | .info-block { 26 | float: left; 27 | width: 350px; 28 | line-height: 25px; 29 | } 30 | 31 | .progress { 32 | display: inline-flex; 33 | width: 160px; 34 | background-color: #2d3348; 35 | height: 1.1rem; 36 | } 37 | 38 | .progress-bar { 39 | padding: 4px; 40 | } 41 | 42 | .bg-warning { 43 | background-color: #b58800 !important; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/app/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { StateService } from 'src/app/services/state.service'; 3 | import { MemPoolState } from 'src/app/interfaces/websocket.interface'; 4 | 5 | @Component({ 6 | selector: 'app-footer', 7 | templateUrl: './footer.component.html', 8 | styleUrls: ['./footer.component.scss'] 9 | }) 10 | export class FooterComponent implements OnInit { 11 | memPoolInfo: MemPoolState | undefined; 12 | mempoolBlocks = 0; 13 | progressWidth = ''; 14 | progressClass: string; 15 | mempoolSize = 0; 16 | 17 | constructor( 18 | private stateService: StateService, 19 | ) { } 20 | 21 | ngOnInit() { 22 | this.stateService.mempoolStats$ 23 | .subscribe((mempoolState) => { 24 | this.memPoolInfo = mempoolState; 25 | this.updateProgress(); 26 | }); 27 | 28 | this.stateService.mempoolBlocks$ 29 | .subscribe((mempoolBlocks) => { 30 | if (!mempoolBlocks.length) { return; } 31 | const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b); 32 | const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b); 33 | this.mempoolSize = size; 34 | this.mempoolBlocks = Math.ceil(vsize / 1000000); 35 | }); 36 | } 37 | 38 | updateProgress() { 39 | if (!this.memPoolInfo) { 40 | return; 41 | } 42 | 43 | const vBytesPerSecondLimit = 1667; 44 | 45 | let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond; 46 | if (vBytesPerSecond > 1667) { 47 | vBytesPerSecond = 1667; 48 | } 49 | 50 | const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100); 51 | this.progressWidth = percent + '%'; 52 | 53 | if (percent <= 75) { 54 | this.progressClass = 'bg-success'; 55 | } else if (percent <= 99) { 56 | this.progressClass = 'bg-warning'; 57 | } else { 58 | this.progressClass = 'bg-danger'; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-blocks/latest-blocks.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
HeightTimestampMinedTransactionsSizeFilled
#{{ block.height }}{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ block.timestamp | timeSince : trigger }} ago{{ block.tx_count }}{{ block.size | bytes: 2 }} 18 |
19 |
20 |
21 |
35 | 36 |
37 |
38 | 39 |
40 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-blocks/latest-blocks.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/app/components/latest-blocks/latest-blocks.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/latest-blocks/latest-blocks.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LatestBlocksComponent } from './latest-blocks.component'; 4 | 5 | describe('LatestBlocksComponent', () => { 6 | let component: LatestBlocksComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ LatestBlocksComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(LatestBlocksComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-blocks/latest-blocks.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { ElectrsApiService } from '../../services/electrs-api.service'; 3 | import { StateService } from '../../services/state.service'; 4 | import { Block } from '../../interfaces/electrs.interface'; 5 | import { Subscription } from 'rxjs'; 6 | 7 | @Component({ 8 | selector: 'app-latest-blocks', 9 | templateUrl: './latest-blocks.component.html', 10 | styleUrls: ['./latest-blocks.component.scss'], 11 | }) 12 | export class LatestBlocksComponent implements OnInit, OnDestroy { 13 | blocks: any[] = []; 14 | blockSubscription: Subscription; 15 | isLoading = true; 16 | interval: any; 17 | trigger = 0; 18 | 19 | constructor( 20 | private electrsApiService: ElectrsApiService, 21 | private stateService: StateService, 22 | ) { } 23 | 24 | ngOnInit() { 25 | this.blockSubscription = this.stateService.blocks$ 26 | .subscribe((block) => { 27 | if (block === null || !this.blocks.length) { 28 | return; 29 | } 30 | 31 | if (block.height === this.blocks[0].height) { 32 | return; 33 | } 34 | 35 | // If we are out of sync, reload the blocks instead 36 | if (block.height > this.blocks[0].height + 1) { 37 | this.loadInitialBlocks(); 38 | return; 39 | } 40 | 41 | if (block.height === this.blocks[0].height) { 42 | return; 43 | } 44 | 45 | this.blocks.pop(); 46 | this.blocks.unshift(block); 47 | }); 48 | 49 | this.loadInitialBlocks(); 50 | this.interval = window.setInterval(() => this.trigger++, 1000 * 60); 51 | } 52 | 53 | ngOnDestroy() { 54 | clearInterval(this.interval); 55 | this.blockSubscription.unsubscribe(); 56 | } 57 | 58 | loadInitialBlocks() { 59 | this.electrsApiService.listBlocks$() 60 | .subscribe((blocks) => { 61 | this.blocks = blocks; 62 | this.isLoading = false; 63 | }); 64 | } 65 | 66 | loadMore() { 67 | this.isLoading = true; 68 | this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1) 69 | .subscribe((blocks) => { 70 | this.blocks = this.blocks.concat(blocks); 71 | this.isLoading = false; 72 | }); 73 | } 74 | 75 | trackByBlock(index: number, block: Block) { 76 | return block.height; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-transactions/latest-transactions.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
Transaction IDValueSizeFee
{{ transaction.txid }}{{ transaction.value / 100000000 }} BTC{{ transaction.vsize | vbytes: 2 }}{{ transaction.fee / transaction.vsize | number : '1.2-2'}} sats/vB
29 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-transactions/latest-transactions.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/app/components/latest-transactions/latest-transactions.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/latest-transactions/latest-transactions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LatestTransactionsComponent } from './latest-transactions.component'; 4 | 5 | describe('LatestTransactionsComponent', () => { 6 | let component: LatestTransactionsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ LatestTransactionsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(LatestTransactionsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/latest-transactions/latest-transactions.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ElectrsApiService } from '../../services/electrs-api.service'; 3 | import { Observable, timer } from 'rxjs'; 4 | import { Recent } from '../../interfaces/electrs.interface'; 5 | import { flatMap, tap } from 'rxjs/operators'; 6 | 7 | @Component({ 8 | selector: 'app-latest-transactions', 9 | templateUrl: './latest-transactions.component.html', 10 | styleUrls: ['./latest-transactions.component.scss'] 11 | }) 12 | export class LatestTransactionsComponent implements OnInit { 13 | transactions$: Observable; 14 | isLoading = true; 15 | 16 | constructor( 17 | private electrsApiService: ElectrsApiService, 18 | ) { } 19 | 20 | ngOnInit() { 21 | this.transactions$ = timer(0, 10000) 22 | .pipe( 23 | flatMap(() => { 24 | return this.electrsApiService.getRecentTransaction$() 25 | .pipe( 26 | tap(() => this.isLoading = false) 27 | ); 28 | }) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/components/master-page/master-page.component.html: -------------------------------------------------------------------------------- 1 |
2 | 30 |
31 | 32 |
33 | 34 | -------------------------------------------------------------------------------- /frontend/src/app/components/master-page/master-page.component.scss: -------------------------------------------------------------------------------- 1 | li.nav-item.active { 2 | background-color: #653b9c; 3 | } 4 | 5 | li.nav-item { 6 | padding: 10px; 7 | } 8 | 9 | .navbar { 10 | z-index: 100; 11 | } 12 | 13 | @media (min-width: 768px) { 14 | .navbar { 15 | padding: 0rem 1rem; 16 | } 17 | li.nav-item { 18 | padding: 20px; 19 | } 20 | } 21 | 22 | .logo { 23 | margin-left: 40px; 24 | } 25 | 26 | li.nav-item a { 27 | color: #ffffff; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/app/components/master-page/master-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { StateService } from '../../services/state.service'; 3 | 4 | @Component({ 5 | selector: 'app-master-page', 6 | templateUrl: './master-page.component.html', 7 | styleUrls: ['./master-page.component.scss'] 8 | }) 9 | export class MasterPageComponent implements OnInit { 10 | navCollapsed = false; 11 | isOffline = false; 12 | 13 | constructor( 14 | private stateService: StateService, 15 | ) { } 16 | 17 | ngOnInit() { 18 | this.stateService.isOffline$ 19 | .subscribe((state) => { 20 | this.isOffline = state; 21 | }); 22 | } 23 | 24 | collapse(): void { 25 | this.navCollapsed = !this.navCollapsed; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/components/mempool-blocks/mempool-blocks.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | ~{{ projectedBlock.medianFee | ceil }} sat/vB 7 |
8 | {{ projectedBlock.feeRange[0] | ceil }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | ceil }} sat/vB 9 |
10 |
{{ projectedBlock.blockSize | bytes: 2 }}
11 |
{{ projectedBlock.nTx }} transactions
12 |
In ~{{ 10 * i + 10 }} minutes
13 | 14 |
+{{ projectedBlock.blockVSize / 1000000 | ceil }} blocks
15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss: -------------------------------------------------------------------------------- 1 | .bitcoin-block { 2 | width: 125px; 3 | height: 125px; 4 | } 5 | 6 | .block-size { 7 | font-size: 18px; 8 | font-weight: bold; 9 | } 10 | 11 | .mempool-blocks-container { 12 | position: absolute; 13 | top: 0px; 14 | right: 0px; 15 | left: 0px; 16 | 17 | animation: opacityPulse 2s ease-out; 18 | animation-iteration-count: infinite; 19 | opacity: 1; 20 | } 21 | 22 | .mempool-block { 23 | position: absolute; 24 | top: 0; 25 | } 26 | 27 | .block-body { 28 | text-align: center; 29 | } 30 | 31 | @keyframes opacityPulse { 32 | 0% {opacity: 0.7;} 33 | 50% {opacity: 1.0;} 34 | 100% {opacity: 0.7;} 35 | } 36 | 37 | .time-difference { 38 | position: absolute; 39 | bottom: 10px; 40 | text-align: center; 41 | width: 100%; 42 | font-size: 14px; 43 | } 44 | 45 | .fees { 46 | font-size: 10px; 47 | margin-top: 10px; 48 | margin-bottom: 2px; 49 | } 50 | 51 | .transaction-count { 52 | font-size: 12px; 53 | } 54 | 55 | @media (max-width: 767.98px) { 56 | .mempool-blocks-container { 57 | position: absolute; 58 | left: -165px; 59 | top: -40px; 60 | } 61 | } 62 | 63 | @media (min-width: 768px) { 64 | .bitcoin-block::after { 65 | content: ''; 66 | width: 125px; 67 | height: 24px; 68 | position:absolute; 69 | top: -24px; 70 | left: -20px; 71 | background-color: #232838; 72 | transform:skew(40deg); 73 | transform-origin:top; 74 | } 75 | 76 | .bitcoin-block::before { 77 | content: ''; 78 | width: 20px; 79 | height: 125px; 80 | position: absolute; 81 | top: -12px; 82 | left: -20px; 83 | background-color: #191c27; 84 | 85 | transform: skewY(50deg); 86 | transform-origin: top; 87 | } 88 | 89 | .mempool-block.bitcoin-block::after { 90 | background-color: #403834; 91 | } 92 | 93 | .mempool-block.bitcoin-block::before { 94 | background-color: #2d2825; 95 | } 96 | } 97 | 98 | .black-background { 99 | background-color: #11131f; 100 | z-index: 100; 101 | position: relative; 102 | } 103 | 104 | #arrow-up { 105 | position: relative; 106 | right: 75px; 107 | top: 140px; 108 | transition: 1s; 109 | width: 0; 110 | height: 0; 111 | border-left: 35px solid transparent; 112 | border-right: 35px solid transparent; 113 | border-bottom: 35px solid #FFF; 114 | } -------------------------------------------------------------------------------- /frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, Input, EventEmitter, Output, OnChanges } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | import { MempoolBlock } from 'src/app/interfaces/websocket.interface'; 4 | import { StateService } from 'src/app/services/state.service'; 5 | 6 | @Component({ 7 | selector: 'app-mempool-blocks', 8 | templateUrl: './mempool-blocks.component.html', 9 | styleUrls: ['./mempool-blocks.component.scss'] 10 | }) 11 | export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { 12 | mempoolBlocks: MempoolBlock[]; 13 | mempoolBlocksSubscription: Subscription; 14 | 15 | blockWidth = 125; 16 | blockPadding = 30; 17 | arrowVisible = false; 18 | 19 | rightPosition = 0; 20 | 21 | @Input() txFeePerVSize: number; 22 | 23 | constructor( 24 | private stateService: StateService, 25 | ) { } 26 | 27 | ngOnInit() { 28 | this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$ 29 | .subscribe((blocks) => { 30 | this.mempoolBlocks = blocks; 31 | this.calculateTransactionPosition(); 32 | }); 33 | } 34 | 35 | ngOnChanges() { 36 | this.calculateTransactionPosition(); 37 | } 38 | 39 | ngOnDestroy() { 40 | this.mempoolBlocksSubscription.unsubscribe(); 41 | } 42 | 43 | trackByFn(index: number) { 44 | return index; 45 | } 46 | 47 | getStyleForMempoolBlockAtIndex(index: number) { 48 | const greenBackgroundHeight = 100 - this.mempoolBlocks[index].blockVSize / 1000000 * 100; 49 | return { 50 | 'right': 40 + index * 155 + 'px', 51 | 'background': `repeating-linear-gradient(to right, #554b45, #554b45 ${greenBackgroundHeight}%, 52 | #bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`, 53 | }; 54 | } 55 | 56 | calculateTransactionPosition() { 57 | if (!this.txFeePerVSize || !this.mempoolBlocks) { 58 | this.arrowVisible = false; 59 | return; 60 | } 61 | 62 | this.arrowVisible = true; 63 | 64 | for (const block of this.mempoolBlocks) { 65 | for (let i = 0; i < block.feeRange.length - 1; i++) { 66 | if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { 67 | const txInBlockIndex = this.mempoolBlocks.indexOf(block); 68 | const feeRangeIndex = block.feeRange.findIndex((val, index) => this.txFeePerVSize < block.feeRange[index + 1]); 69 | const feeRangeChunkSize = 1 / (block.feeRange.length - 1); 70 | 71 | const txFee = this.txFeePerVSize - block.feeRange[i]; 72 | const max = block.feeRange[i + 1] - block.feeRange[i]; 73 | const blockLocation = txFee / max; 74 | 75 | const chunkPositionOffset = blockLocation * feeRangeChunkSize; 76 | const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; 77 | 78 | const blockedFilledPercentage = (block.blockVSize > 1000000 ? 1000000 : block.blockVSize) / 1000000; 79 | const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding) 80 | + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); 81 | 82 | this.rightPosition = arrowRightPosition; 83 | break; 84 | } 85 | } 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /frontend/src/app/components/qrcode/qrcode.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/components/qrcode/qrcode.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/app/components/qrcode/qrcode.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/qrcode/qrcode.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { QrcodeComponent } from './qrcode.component'; 4 | 5 | describe('QrcodeComponent', () => { 6 | let component: QrcodeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ QrcodeComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(QrcodeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/qrcode/qrcode.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; 2 | import * as QRCode from 'qrcode/build/qrcode.js'; 3 | 4 | @Component({ 5 | selector: 'app-qrcode', 6 | templateUrl: './qrcode.component.html', 7 | styleUrls: ['./qrcode.component.scss'] 8 | }) 9 | export class QrcodeComponent implements AfterViewInit, OnDestroy { 10 | @Input() data: string; 11 | @ViewChild('canvas') canvas: ElementRef; 12 | 13 | qrcodeObject: any; 14 | 15 | constructor() { } 16 | 17 | ngAfterViewInit() { 18 | const opts = { 19 | errorCorrectionLevel: 'H', 20 | margin: 0, 21 | color: { 22 | dark: '#000', 23 | light: '#fff' 24 | }, 25 | width: 125, 26 | height: 125, 27 | }; 28 | 29 | if (!this.data) { 30 | return; 31 | } 32 | 33 | QRCode.toCanvas(this.canvas.nativeElement, this.data.toUpperCase(), opts, (error: any) => { 34 | if (error) { 35 | console.error(error); 36 | } 37 | }); 38 | } 39 | 40 | ngOnDestroy() { 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/app/components/search-form/search-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 | 8 |
9 |
10 |
-------------------------------------------------------------------------------- /frontend/src/app/components/search-form/search-form.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/app/components/search-form/search-form.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/search-form/search-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SearchFormComponent } from './search-form.component'; 4 | 5 | describe('SearchFormComponent', () => { 6 | let component: SearchFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ SearchFormComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(SearchFormComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/search-form/search-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | @Component({ 6 | selector: 'app-search-form', 7 | templateUrl: './search-form.component.html', 8 | styleUrls: ['./search-form.component.scss'], 9 | changeDetection: ChangeDetectionStrategy.OnPush 10 | }) 11 | export class SearchFormComponent implements OnInit { 12 | searchForm: FormGroup; 13 | 14 | searchButtonText = 'Search'; 15 | searchBoxPlaceholderText = 'Transaction, address, block hash...'; 16 | 17 | regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,87})$/; 18 | regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; 19 | regexTransaction = /^[a-fA-F0-9]{64}$/; 20 | 21 | constructor( 22 | private formBuilder: FormBuilder, 23 | private router: Router, 24 | ) { } 25 | 26 | ngOnInit() { 27 | this.searchForm = this.formBuilder.group({ 28 | searchText: ['', Validators.required], 29 | }); 30 | } 31 | 32 | search() { 33 | const searchText = this.searchForm.value.searchText.trim(); 34 | if (searchText) { 35 | if (this.regexAddress.test(searchText)) { 36 | this.router.navigate(['/address/', searchText]); 37 | } else if (this.regexBlockhash.test(searchText)) { 38 | this.router.navigate(['/block/', searchText]); 39 | } else if (this.regexTransaction.test(searchText)) { 40 | this.router.navigate(['/tx/', searchText]); 41 | } else { 42 | return; 43 | } 44 | this.searchForm.setValue({ 45 | searchText: '', 46 | }); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/app/components/start/start.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/app/components/start/start.component.scss: -------------------------------------------------------------------------------- 1 | .search-container { 2 | padding-top: 50px; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/components/start/start.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { StartComponent } from './start.component'; 4 | 5 | describe('StartComponent', () => { 6 | let component: StartComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ StartComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(StartComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/start/start.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { WebsocketService } from 'src/app/services/websocket.service'; 3 | 4 | @Component({ 5 | selector: 'app-start', 6 | templateUrl: './start.component.html', 7 | styleUrls: ['./start.component.scss'] 8 | }) 9 | export class StartComponent implements OnInit { 10 | view: 'blocks' | 'transactions' = 'blocks'; 11 | 12 | constructor( 13 | private websocketService: WebsocketService, 14 | ) { } 15 | 16 | ngOnInit() { 17 | this.websocketService.want(['blocks', 'stats', 'mempool-blocks']); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/components/statistics/chartist.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles.scss"; 2 | 3 | .ct-bar-label { 4 | font-size: 20px; 5 | font-weight: bold; 6 | fill: #fff; 7 | } 8 | 9 | .ct-target-line { 10 | stroke: #f5f5f5; 11 | stroke-width: 3px; 12 | stroke-dasharray: 7px; 13 | } 14 | 15 | .ct-area { 16 | stroke: none; 17 | fill-opacity: 0.9; 18 | } 19 | 20 | .ct-label { 21 | fill: rgba(255, 255, 255, 0.4); 22 | color: rgba(255, 255, 255, 0.4); 23 | } 24 | 25 | .ct-grid { 26 | stroke: rgba(255, 255, 255, 0.2); 27 | } 28 | 29 | /* LEGEND */ 30 | 31 | .ct-legend { 32 | position: absolute; 33 | z-index: 10; 34 | left: 0px; 35 | list-style: none; 36 | font-size: 13px; 37 | padding: 0px 0px 0px 30px; 38 | top: 90px; 39 | 40 | li { 41 | position: relative; 42 | padding-left: 23px; 43 | margin-bottom: 0px; 44 | } 45 | 46 | li:before { 47 | width: 12px; 48 | height: 12px; 49 | position: absolute; 50 | left: 0; 51 | content: ''; 52 | border: 3px solid transparent; 53 | border-radius: 2px; 54 | } 55 | 56 | li.inactive:before { 57 | background: transparent; 58 | } 59 | 60 | &.ct-legend-inside { 61 | position: absolute; 62 | top: 0; 63 | right: 0; 64 | } 65 | 66 | @for $i from 0 to length($ct-series-colors) { 67 | .ct-series-#{$i}:before { 68 | background-color: nth($ct-series-colors, $i + 1); 69 | border-color: nth($ct-series-colors, $i + 1); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /frontend/src/app/components/statistics/statistics.component.html: -------------------------------------------------------------------------------- 1 |
2 | 14 | 15 |
16 |
17 |
18 |

Loading graphs...

19 |
20 |
21 |
22 |
23 | 24 |
25 | 26 |
27 |
28 | Mempool by vbytes (satoshis/vbyte) 29 | 30 |
31 |
32 |
33 | 36 | 39 | 42 | 45 | 48 | 51 | 54 |
55 |
56 |
57 |
58 |
59 | 63 | 64 |
65 |
66 |
67 |
68 | 69 |
70 |
71 |
72 | Transactions weight per second (vBytes/s)
73 |
74 |
75 | 79 | 80 |
81 | 82 |
83 |
84 |
85 | 86 |
87 | 88 |
89 | -------------------------------------------------------------------------------- /frontend/src/app/components/statistics/statistics.component.scss: -------------------------------------------------------------------------------- 1 | .card-header { 2 | border-bottom: 0; 3 | background-color: none; 4 | font-size: 20px; 5 | } 6 | 7 | .card { 8 | background-color: transparent; 9 | border: 0; 10 | } 11 | 12 | .bootstrap-spinner { 13 | width: 22px; 14 | height: 22px; 15 | margin-right: 10px; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/components/television/television.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 | 7 |
8 | 12 | 13 |
14 | 15 |
16 |
17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 | 25 |
-------------------------------------------------------------------------------- /frontend/src/app/components/television/television.component.scss: -------------------------------------------------------------------------------- 1 | #tv-wrapper { 2 | height: 100%; 3 | padding: 10px; 4 | padding-top: 20px; 5 | } 6 | 7 | .blockchain-wrapper { 8 | overflow: hidden; 9 | } 10 | 11 | .position-container { 12 | position: absolute; 13 | left: 50%; 14 | bottom: 150px; 15 | } 16 | 17 | .chart-holder { 18 | height: calc(100% - 220px); 19 | } 20 | 21 | #divider { 22 | width: 3px; 23 | height: 175px; 24 | left: 0; 25 | top: -40px; 26 | background-image: url('/assets/divider-new.png'); 27 | background-repeat: repeat-y; 28 | position: absolute; 29 | } 30 | 31 | #divider > img { 32 | position: absolute; 33 | left: -100px; 34 | top: -28px; 35 | } 36 | 37 | @media (min-width: 1920px) { 38 | .position-container { 39 | transform: scale(1.3); 40 | bottom: 190px; 41 | } 42 | .chart-holder { 43 | height: calc(100% - 280px); 44 | } 45 | } 46 | 47 | :host ::ng-deep .ct-legend { 48 | top: 25px; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/app/components/television/television.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, LOCALE_ID, Inject, Renderer2 } from '@angular/core'; 2 | import { formatDate } from '@angular/common'; 3 | import { VbytesPipe } from '../../pipes/bytes-pipe/vbytes.pipe'; 4 | 5 | import * as Chartist from 'chartist'; 6 | import { WebsocketService } from 'src/app/services/websocket.service'; 7 | import { OptimizedMempoolStats } from '../../interfaces/node-api.interface'; 8 | import { StateService } from 'src/app/services/state.service'; 9 | import { ApiService } from 'src/app/services/api.service'; 10 | 11 | @Component({ 12 | selector: 'app-television', 13 | templateUrl: './television.component.html', 14 | styleUrls: ['./television.component.scss'] 15 | }) 16 | export class TelevisionComponent implements OnInit { 17 | loading = true; 18 | 19 | mempoolStats: OptimizedMempoolStats[] = []; 20 | mempoolVsizeFeesData: any; 21 | mempoolVsizeFeesOptions: any; 22 | 23 | constructor( 24 | private websocketService: WebsocketService, 25 | @Inject(LOCALE_ID) private locale: string, 26 | private vbytesPipe: VbytesPipe, 27 | private apiService: ApiService, 28 | private stateService: StateService, 29 | ) { } 30 | 31 | ngOnInit() { 32 | this.websocketService.want(['blocks', 'live-2h-chart']); 33 | 34 | const labelInterpolationFnc = (value: any, index: any) => { 35 | return index % 6 === 0 ? formatDate(value, 'HH:mm', this.locale) : null; 36 | }; 37 | 38 | this.mempoolVsizeFeesOptions = { 39 | showArea: true, 40 | showLine: false, 41 | fullWidth: true, 42 | showPoint: false, 43 | low: 0, 44 | axisX: { 45 | labelInterpolationFnc: labelInterpolationFnc, 46 | offset: 40 47 | }, 48 | axisY: { 49 | labelInterpolationFnc: (value: number): any => { 50 | return this.vbytesPipe.transform(value, 2); 51 | }, 52 | offset: 160 53 | }, 54 | plugins: [ 55 | Chartist.plugins.ctTargetLine({ 56 | value: 1000000 57 | }), 58 | Chartist.plugins.legend({ 59 | legendNames: [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 60 | 250, 300, 350, 400].map((sats, i, arr) => { 61 | if (sats === 400) { 62 | return '350+'; 63 | } 64 | if (i === 0) { 65 | return '1 sat/vbyte'; 66 | } 67 | return arr[i - 1] + ' - ' + sats; 68 | }) 69 | }) 70 | ] 71 | }; 72 | 73 | this.apiService.list2HStatistics$() 74 | .subscribe((mempoolStats) => { 75 | this.mempoolStats = mempoolStats; 76 | this.handleNewMempoolData(this.mempoolStats.concat([])); 77 | this.loading = false; 78 | }); 79 | 80 | this.stateService.live2Chart$ 81 | .subscribe((mempoolStats) => { 82 | this.mempoolStats.unshift(mempoolStats); 83 | this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - 1); 84 | this.handleNewMempoolData(this.mempoolStats.concat([])); 85 | }); 86 | } 87 | 88 | handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { 89 | mempoolStats.reverse(); 90 | const labels = mempoolStats.map(stats => stats.added); 91 | 92 | const finalArrayVbyte = this.generateArray(mempoolStats); 93 | 94 | // Remove the 0-1 fee vbyte since it's practially empty 95 | finalArrayVbyte.shift(); 96 | 97 | this.mempoolVsizeFeesData = { 98 | labels: labels, 99 | series: finalArrayVbyte 100 | }; 101 | } 102 | 103 | generateArray(mempoolStats: OptimizedMempoolStats[]) { 104 | const finalArray: number[][] = []; 105 | let feesArray: number[] = []; 106 | 107 | for (let index = 37; index > -1; index--) { 108 | feesArray = []; 109 | mempoolStats.forEach((stats) => { 110 | const theFee = stats.vsizes[index].toString(); 111 | if (theFee) { 112 | feesArray.push(parseInt(theFee, 10)); 113 | } else { 114 | feesArray.push(0); 115 | } 116 | }); 117 | if (finalArray.length) { 118 | feesArray = feesArray.map((value, i) => value + finalArray[finalArray.length - 1][i]); 119 | } 120 | finalArray.push(feesArray); 121 | } 122 | finalArray.reverse(); 123 | return finalArray; 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /frontend/src/app/components/time-since/time-since.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-time-since', 5 | template: `{{ time | timeSince : trigger }}`, 6 | changeDetection: ChangeDetectionStrategy.OnPush 7 | }) 8 | export class TimeSinceComponent implements OnInit, OnDestroy { 9 | interval: number; 10 | trigger = 0; 11 | 12 | @Input() time: number; 13 | 14 | constructor( 15 | private ref: ChangeDetectorRef 16 | ) { } 17 | 18 | ngOnInit() { 19 | this.interval = window.setInterval(() => { 20 | this.trigger++; 21 | this.ref.markForCheck(); 22 | }, 1000 * 60); 23 | } 24 | 25 | ngOnDestroy() { 26 | clearInterval(this.interval); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/app/components/transaction/transaction.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |

Transaction

7 | {{ txId }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
Included in block#{{ tx.status.block_height }} at {{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} ( ago)
Fees{{ tx.fee | number }} sats ({{ conversions.USD * tx.fee / 100000000 | currency:'USD':'symbol':'1.2-2' }})
Fees per vByte{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB
44 |
45 | 46 |
47 | 48 |
49 | 50 | 51 | 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
Fees{{ tx.fee | number }} sats ({{ conversions.USD * tx.fee / 100000000 | currency:'USD':'symbol':'1.2-2' }})
Fees per vByte{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB
66 |
67 |
68 | 69 |
70 | 71 |

Inputs & Outputs

72 | 73 | 74 | 75 |

Details

76 |
77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
Size{{ tx.size | bytes: 2 }}
Weight{{ tx.weight | wuBytes: 2 }}
90 |
91 |
92 | 93 |
94 | 95 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 |
110 |
111 | 112 |
113 | 114 |
115 |
116 |

117 |
118 |
119 | 120 | 121 |
122 | Error loading transaction data. 123 |
124 | {{ error.error }} 125 |
126 |
127 |
128 | 129 |
130 | -------------------------------------------------------------------------------- /frontend/src/app/components/transaction/transaction.component.scss: -------------------------------------------------------------------------------- 1 | .adjust-btn-padding { 2 | padding: 0.55rem; 3 | } 4 | 5 | #arrow { 6 | position: absolute; 7 | bottom: -24px; 8 | width: 40px; 9 | right: -1px; 10 | 11 | width: 40px; 12 | } 13 | 14 | .title-block { 15 | color: #FFF; 16 | padding-left: 10px; 17 | padding-top: 20px; 18 | padding-bottom: 3px; 19 | border-top: 5px solid #FFF; 20 | } 21 | 22 | .title-block > h1 { 23 | margin: 0; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/app/components/transaction/transaction.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { ElectrsApiService } from '../../services/electrs-api.service'; 3 | import { ActivatedRoute, ParamMap } from '@angular/router'; 4 | import { switchMap } from 'rxjs/operators'; 5 | import { Transaction, Block } from '../../interfaces/electrs.interface'; 6 | import { of } from 'rxjs'; 7 | import { StateService } from '../../services/state.service'; 8 | import { WebsocketService } from '../../services/websocket.service'; 9 | 10 | @Component({ 11 | selector: 'app-transaction', 12 | templateUrl: './transaction.component.html', 13 | styleUrls: ['./transaction.component.scss'] 14 | }) 15 | export class TransactionComponent implements OnInit, OnDestroy { 16 | tx: Transaction; 17 | txId: string; 18 | isLoadingTx = true; 19 | conversions: any; 20 | error: any = undefined; 21 | latestBlock: Block; 22 | 23 | rightPosition = 0; 24 | blockDepth = 0; 25 | 26 | constructor( 27 | private route: ActivatedRoute, 28 | private electrsApiService: ElectrsApiService, 29 | private stateService: StateService, 30 | private websocketService: WebsocketService, 31 | ) { } 32 | 33 | ngOnInit() { 34 | this.websocketService.want(['blocks', 'mempool-blocks']); 35 | 36 | this.route.paramMap.pipe( 37 | switchMap((params: ParamMap) => { 38 | this.txId = params.get('id') || ''; 39 | this.error = undefined; 40 | this.isLoadingTx = true; 41 | if (history.state.data) { 42 | return of(history.state.data); 43 | } else { 44 | return this.electrsApiService.getTransaction$(this.txId); 45 | } 46 | }) 47 | ) 48 | .subscribe((tx: Transaction) => { 49 | this.tx = tx; 50 | this.isLoadingTx = false; 51 | document.body.scrollTo({ top: 0, behavior: 'smooth' }); 52 | 53 | if (!tx.status.confirmed) { 54 | this.websocketService.startTrackTransaction(tx.txid); 55 | } 56 | }, 57 | (error) => { 58 | this.error = error; 59 | this.isLoadingTx = false; 60 | }); 61 | 62 | this.stateService.conversions$ 63 | .subscribe((conversions) => this.conversions = conversions); 64 | 65 | this.stateService.blocks$ 66 | .subscribe((block) => this.latestBlock = block); 67 | 68 | this.stateService.txConfirmed$ 69 | .subscribe((block) => { 70 | this.tx.status = { 71 | confirmed: true, 72 | block_height: block.height, 73 | block_hash: block.id, 74 | block_time: block.timestamp, 75 | }; 76 | }); 77 | } 78 | 79 | ngOnDestroy() { 80 | this.websocketService.startTrackTransaction('stop'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/app/components/transactions-list/transactions-list.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {{ tx.txid }} 4 |
{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }}
5 |
6 |
7 |
8 |
9 | 10 | 11 | 12 | 22 | 35 | 38 | 39 | 40 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 |
24 | 25 | Coinbase (Newly Generated Coins) 26 | 27 | 28 | {{ vin.prevout.scriptpubkey_address | shortenString : 42 }} 29 |
30 | 31 |
32 |
33 |
34 |
36 | 37 |
41 | 42 |
43 |
44 | 45 | 46 | 47 | 58 | 61 | 70 | 71 | 72 | 84 | 85 | 86 |
48 | {{ vout.scriptpubkey_address | shortenString : 42 }} 49 | 50 | OP_RETURN 51 | 52 | 57 | 59 | 60 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
73 | 74 | 75 | 76 | 77 | 78 |   79 | 80 | 83 |
87 |
88 |
89 |
90 | 91 |
92 | 93 |
94 | -------------------------------------------------------------------------------- /frontend/src/app/components/transactions-list/transactions-list.component.scss: -------------------------------------------------------------------------------- 1 | .header-bg { 2 | font-size: 14px; 3 | } 4 | 5 | .arrow-td { 6 | width: 22px; 7 | } 8 | 9 | .arrow { 10 | display: inline-block!important; 11 | position: relative; 12 | width: 14px; 13 | height: 22px; 14 | box-sizing: content-box 15 | } 16 | 17 | .arrow:before { 18 | position: absolute; 19 | content: ''; 20 | margin: auto; 21 | top: 0; 22 | bottom: 0; 23 | left: 0; 24 | right: calc(-1*30px/3); 25 | width: 0; 26 | height: 0; 27 | border-top: 6.66px solid transparent; 28 | border-bottom: 6.66px solid transparent 29 | } 30 | 31 | .arrow:after { 32 | position: absolute; 33 | content: ''; 34 | margin: auto; 35 | top: 0; 36 | bottom: 0; 37 | left: 0; 38 | right: calc(30px/6); 39 | width: calc(30px/3); 40 | height: calc(20px/3); 41 | background: rgba(0, 0, 0, 0); 42 | } 43 | 44 | .arrow.green:before { 45 | border-left: 10px solid #28a745; 46 | } 47 | .arrow.green:after { 48 | background-color:#28a745; 49 | } 50 | 51 | .arrow.red:before { 52 | border-left: 10px solid #dc3545; 53 | } 54 | .arrow.red:after { 55 | background-color:#dc3545; 56 | } 57 | 58 | .arrow.grey:before { 59 | border-left: 10px solid #6c757d; 60 | } 61 | 62 | .arrow.grey:after { 63 | background-color:#6c757d; 64 | } -------------------------------------------------------------------------------- /frontend/src/app/components/transactions-list/transactions-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, ChangeDetectorRef } from '@angular/core'; 2 | import { StateService } from '../../services/state.service'; 3 | import { Observable, forkJoin } from 'rxjs'; 4 | import { Block, Outspend } from '../../interfaces/electrs.interface'; 5 | import { ElectrsApiService } from '../../services/electrs-api.service'; 6 | 7 | @Component({ 8 | selector: 'app-transactions-list', 9 | templateUrl: './transactions-list.component.html', 10 | styleUrls: ['./transactions-list.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush 12 | }) 13 | export class TransactionsListComponent implements OnInit, OnChanges { 14 | @Input() transactions: any[]; 15 | @Input() showConfirmations = false; 16 | @Input() transactionPage = false; 17 | 18 | latestBlock$: Observable; 19 | outspends: Outspend[] = []; 20 | 21 | constructor( 22 | private stateService: StateService, 23 | private electrsApiService: ElectrsApiService, 24 | private ref: ChangeDetectorRef, 25 | ) { } 26 | 27 | ngOnInit() { 28 | this.latestBlock$ = this.stateService.blocks$; 29 | } 30 | 31 | ngOnChanges() { 32 | if (!this.transactions || !this.transactions.length) { 33 | return; 34 | } 35 | const observableObject = {}; 36 | this.transactions.forEach((tx, i) => { 37 | if (this.outspends[i]) { 38 | return; 39 | } 40 | observableObject[i] = this.electrsApiService.getOutspends$(tx.txid); 41 | }); 42 | 43 | forkJoin(observableObject) 44 | .subscribe((outspends: any) => { 45 | const newOutspends = []; 46 | for (const i in outspends) { 47 | if (outspends.hasOwnProperty(i)) { 48 | newOutspends.push(outspends[i]); 49 | } 50 | } 51 | this.outspends = this.outspends.concat(newOutspends); 52 | this.ref.markForCheck(); 53 | }); 54 | } 55 | 56 | getTotalTxOutput(tx: any) { 57 | return tx.vout.map((v: any) => v.value || 0).reduce((a: number, b: number) => a + b); 58 | } 59 | 60 | switchCurrency() { 61 | const oldvalue = !this.stateService.viewFiat$.value; 62 | this.stateService.viewFiat$.next(oldvalue); 63 | } 64 | 65 | trackByFn(index: number) { 66 | return index; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/app/interfaces/electrs.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Transaction { 2 | txid: string; 3 | version: number; 4 | locktime: number; 5 | fee: number; 6 | size: number; 7 | weight: number; 8 | vin: Vin[]; 9 | vout: Vout[]; 10 | status: Status; 11 | } 12 | 13 | export interface Recent { 14 | txid: string; 15 | fee: number; 16 | vsize: number; 17 | value: number; 18 | } 19 | 20 | export interface Prevout { 21 | scriptpubkey: string; 22 | scriptpubkey_asm: string; 23 | scriptpubkey_type: string; 24 | scriptpubkey_address: string; 25 | value: number; 26 | } 27 | 28 | export interface Vin { 29 | txid: string; 30 | vout: number; 31 | prevout: Prevout; 32 | scriptsig: string; 33 | scriptsig_asm: string; 34 | inner_redeemscript_asm?: string; 35 | is_coinbase: boolean; 36 | sequence: any; 37 | witness?: string[]; 38 | inner_witnessscript_asm?: string; 39 | } 40 | 41 | export interface Vout { 42 | scriptpubkey: string; 43 | scriptpubkey_asm: string; 44 | scriptpubkey_type: string; 45 | scriptpubkey_address: string; 46 | value: number; 47 | } 48 | 49 | export interface Status { 50 | confirmed: boolean; 51 | block_height?: number; 52 | block_hash?: string; 53 | block_time?: number; 54 | } 55 | 56 | export interface Block { 57 | id: string; 58 | height: number; 59 | version: number; 60 | timestamp: number; 61 | tx_count: number; 62 | size: number; 63 | weight: number; 64 | merkle_root: string; 65 | previousblockhash: string; 66 | 67 | medianFee?: number; 68 | feeRange?: number[]; 69 | } 70 | 71 | export interface Address { 72 | address: string; 73 | chain_stats: ChainStats; 74 | mempool_stats: MempoolStats; 75 | } 76 | 77 | export interface ChainStats { 78 | funded_txo_count: number; 79 | funded_txo_sum: number; 80 | spent_txo_count: number; 81 | spent_txo_sum: number; 82 | tx_count: number; 83 | } 84 | 85 | export interface MempoolStats { 86 | funded_txo_count: number; 87 | funded_txo_sum: number; 88 | spent_txo_count: number; 89 | spent_txo_sum: number; 90 | tx_count: number; 91 | } 92 | 93 | export interface Outspend { 94 | spent: boolean; 95 | txid: string; 96 | vin: number; 97 | status: Status; 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/app/interfaces/node-api.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface BlockTransaction { 3 | f: number; 4 | } 5 | 6 | export interface OptimizedMempoolStats { 7 | id: number; 8 | added: string; 9 | unconfirmed_transactions: number; 10 | tx_per_second: number; 11 | vbytes_per_second: number; 12 | total_fee: number; 13 | mempool_byte_weight: number; 14 | vsizes: number[] | string[]; 15 | } 16 | 17 | interface FeeData { 18 | vsize: { [ fee: string ]: number }; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/interfaces/websocket.interface.ts: -------------------------------------------------------------------------------- 1 | import { Block } from './electrs.interface'; 2 | 3 | export interface WebsocketResponse { 4 | block?: Block; 5 | blocks?: Block[]; 6 | conversions?: any; 7 | txConfirmed?: boolean; 8 | historicalDate?: string; 9 | mempoolInfo?: MempoolInfo; 10 | vBytesPerSecond?: number; 11 | action?: string; 12 | data?: string[]; 13 | 'track-tx'?: string; 14 | 'track-address'?: string; 15 | } 16 | 17 | export interface MempoolBlock { 18 | blockSize: number; 19 | blockVSize: number; 20 | nTx: number; 21 | medianFee: number; 22 | feeRange: number[]; 23 | } 24 | 25 | export interface MemPoolState { 26 | memPoolInfo: MempoolInfo; 27 | vBytesPerSecond: number; 28 | } 29 | 30 | export interface MempoolInfo { 31 | size: number; 32 | bytes: number; 33 | usage?: number; 34 | maxmempool?: number; 35 | mempoolminfee?: number; 36 | minrelaytxfee?: number; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/bytes-pipe/bytes.pipe.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | import { Pipe, PipeTransform } from '@angular/core'; 3 | import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils'; 4 | 5 | export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB'; 6 | 7 | @Pipe({ 8 | name: 'bytes' 9 | }) 10 | export class BytesPipe implements PipeTransform { 11 | 12 | static formats: { [key: string]: { max: number, prev?: ByteUnit } } = { 13 | 'B': {max: 1000}, 14 | 'kB': {max: Math.pow(1000, 2), prev: 'B'}, 15 | 'MB': {max: Math.pow(1000, 3), prev: 'kB'}, 16 | 'GB': {max: Math.pow(1000, 4), prev: 'MB'}, 17 | 'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'} 18 | }; 19 | 20 | transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any { 21 | 22 | if (!(isNumberFinite(input) && 23 | isNumberFinite(decimal) && 24 | isInteger(decimal) && 25 | isPositive(decimal))) { 26 | return input; 27 | } 28 | 29 | let bytes = input; 30 | let unit = from; 31 | while (unit !== 'B') { 32 | bytes *= 1024; 33 | unit = BytesPipe.formats[unit].prev!; 34 | } 35 | 36 | if (to) { 37 | const format = BytesPipe.formats[to]; 38 | 39 | const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); 40 | 41 | return BytesPipe.formatResult(result, to); 42 | } 43 | 44 | for (const key in BytesPipe.formats) { 45 | const format = BytesPipe.formats[key]; 46 | if (bytes < format.max) { 47 | 48 | const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); 49 | 50 | return BytesPipe.formatResult(result, key); 51 | } 52 | } 53 | } 54 | 55 | static formatResult(result: number, unit: string): string { 56 | return `${result} ${unit}`; 57 | } 58 | 59 | static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { 60 | const prev = format.prev ? BytesPipe.formats[format.prev] : undefined; 61 | return prev ? bytes / prev.max : bytes; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/bytes-pipe/vbytes.pipe.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | import { Pipe, PipeTransform } from '@angular/core'; 3 | import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils'; 4 | 5 | export type ByteUnit = 'vB' | 'kvB' | 'MvB' | 'GvB' | 'TvB'; 6 | 7 | @Pipe({ 8 | name: 'vbytes' 9 | }) 10 | export class VbytesPipe implements PipeTransform { 11 | 12 | static formats: { [key: string]: { max: number, prev?: ByteUnit } } = { 13 | 'vB': {max: 1000}, 14 | 'kvB': {max: Math.pow(1000, 2), prev: 'vB'}, 15 | 'MvB': {max: Math.pow(1000, 3), prev: 'kvB'}, 16 | 'GvB': {max: Math.pow(1000, 4), prev: 'MvB'}, 17 | 'TvB': {max: Number.MAX_SAFE_INTEGER, prev: 'GvB'} 18 | }; 19 | 20 | transform(input: any, decimal: number = 0, from: ByteUnit = 'vB', to?: ByteUnit): any { 21 | 22 | if (!(isNumberFinite(input) && 23 | isNumberFinite(decimal) && 24 | isInteger(decimal) && 25 | isPositive(decimal))) { 26 | return input; 27 | } 28 | 29 | let bytes = input; 30 | let unit = from; 31 | while (unit !== 'vB') { 32 | bytes *= 1024; 33 | unit = VbytesPipe.formats[unit].prev!; 34 | } 35 | 36 | if (to) { 37 | const format = VbytesPipe.formats[to]; 38 | 39 | const result = toDecimal(VbytesPipe.calculateResult(format, bytes), decimal); 40 | 41 | return VbytesPipe.formatResult(result, to); 42 | } 43 | 44 | for (const key in VbytesPipe.formats) { 45 | const format = VbytesPipe.formats[key]; 46 | if (bytes < format.max) { 47 | 48 | const result = toDecimal(VbytesPipe.calculateResult(format, bytes), decimal); 49 | 50 | return VbytesPipe.formatResult(result, key); 51 | } 52 | } 53 | } 54 | 55 | static formatResult(result: number, unit: string): string { 56 | return `${result} ${unit}`; 57 | } 58 | 59 | static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { 60 | const prev = format.prev ? VbytesPipe.formats[format.prev] : undefined; 61 | return prev ? bytes / prev.max : bytes; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/bytes-pipe/wubytes.pipe.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | import { Pipe, PipeTransform } from '@angular/core'; 3 | import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils'; 4 | 5 | export type ByteUnit = 'WU' | 'kWU' | 'MWU' | 'GWU' | 'TWU'; 6 | 7 | @Pipe({ 8 | name: 'wuBytes' 9 | }) 10 | export class WuBytesPipe implements PipeTransform { 11 | 12 | static formats: { [key: string]: { max: number, prev?: ByteUnit } } = { 13 | 'WU': {max: 1000}, 14 | 'kWU': {max: Math.pow(1000, 2), prev: 'WU'}, 15 | 'MWU': {max: Math.pow(1000, 3), prev: 'kWU'}, 16 | 'GWU': {max: Math.pow(1000, 4), prev: 'MWU'}, 17 | 'TWU': {max: Number.MAX_SAFE_INTEGER, prev: 'GWU'} 18 | }; 19 | 20 | transform(input: any, decimal: number = 0, from: ByteUnit = 'WU', to?: ByteUnit): any { 21 | 22 | if (!(isNumberFinite(input) && 23 | isNumberFinite(decimal) && 24 | isInteger(decimal) && 25 | isPositive(decimal))) { 26 | return input; 27 | } 28 | 29 | let bytes = input; 30 | let unit = from; 31 | while (unit !== 'WU') { 32 | bytes *= 1024; 33 | unit = WuBytesPipe.formats[unit].prev!; 34 | } 35 | 36 | if (to) { 37 | const format = WuBytesPipe.formats[to]; 38 | 39 | const result = toDecimal(WuBytesPipe.calculateResult(format, bytes), decimal); 40 | 41 | return WuBytesPipe.formatResult(result, to); 42 | } 43 | 44 | for (const key in WuBytesPipe.formats) { 45 | const format = WuBytesPipe.formats[key]; 46 | if (bytes < format.max) { 47 | 48 | const result = toDecimal(WuBytesPipe.calculateResult(format, bytes), decimal); 49 | 50 | return WuBytesPipe.formatResult(result, key); 51 | } 52 | } 53 | } 54 | 55 | static formatResult(result: number, unit: string): string { 56 | return `${result} ${unit}`; 57 | } 58 | 59 | static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { 60 | const prev = format.prev ? WuBytesPipe.formats[format.prev] : undefined; 61 | return prev ? bytes / prev.max : bytes; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/math-ceil/math-ceil.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'ceil' }) 4 | export class CeilPipe implements PipeTransform { 5 | transform(nr: number) { 6 | return Math.ceil(nr); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/shorten-string-pipe/shorten-string.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'shortenString' }) 4 | export class ShortenStringPipe implements PipeTransform { 5 | transform(str: string, length: number = 12) { 6 | if (!str) { return; } 7 | if (str.length <= length) { 8 | return str; 9 | } 10 | const half = length / 2; 11 | return str.substring(0, half) + '...' + str.substring(str.length - half); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/time-since/time-since.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'timeSince' }) 4 | export class TimeSincePipe implements PipeTransform { 5 | transform(value: any, args?: any): any { 6 | if (value) { 7 | const seconds = Math.floor((+new Date() - +new Date(value * 1000)) / 1000); 8 | if (seconds < 60) { 9 | return '< 1 minute'; 10 | } 11 | const intervals = { 12 | year: 31536000, 13 | month: 2592000, 14 | week: 604800, 15 | day: 86400, 16 | hour: 3600, 17 | minute: 60, 18 | second: 1 19 | }; 20 | let counter; 21 | for (const i in intervals) { 22 | if (intervals.hasOwnProperty(i)) { 23 | counter = Math.floor(seconds / intervals[i]); 24 | if (counter > 0) { 25 | if (counter === 1) { 26 | return counter + ' ' + i; // singular (1 day ago) 27 | } else { 28 | return counter + ' ' + i + 's'; // plural (2 days ago) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | return value; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/app/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { OptimizedMempoolStats } from '../interfaces/node-api.interface'; 4 | import { Observable } from 'rxjs'; 5 | 6 | const API_BASE_URL = '/api/v1'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class ApiService { 12 | constructor( 13 | private httpClient: HttpClient, 14 | ) { } 15 | 16 | list2HStatistics$(): Observable { 17 | return this.httpClient.get(API_BASE_URL + '/statistics/2h'); 18 | } 19 | 20 | list24HStatistics$(): Observable { 21 | return this.httpClient.get(API_BASE_URL + '/statistics/24h'); 22 | } 23 | 24 | list1WStatistics$(): Observable { 25 | return this.httpClient.get(API_BASE_URL + '/statistics/1w'); 26 | } 27 | 28 | list1MStatistics$(): Observable { 29 | return this.httpClient.get(API_BASE_URL + '/statistics/1m'); 30 | } 31 | 32 | list3MStatistics$(): Observable { 33 | return this.httpClient.get(API_BASE_URL + '/statistics/3m'); 34 | } 35 | 36 | list6MStatistics$(): Observable { 37 | return this.httpClient.get(API_BASE_URL + '/statistics/6m'); 38 | } 39 | 40 | list1YStatistics$(): Observable { 41 | return this.httpClient.get(API_BASE_URL + '/statistics/1y'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/app/services/electrs-api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { Block, Transaction, Address, Outspend, Recent } from '../interfaces/electrs.interface'; 5 | 6 | const API_BASE_URL = 'https://www.blockstream.info/api'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class ElectrsApiService { 12 | constructor( 13 | private httpClient: HttpClient, 14 | ) { 15 | } 16 | 17 | getBlock$(hash: string): Observable { 18 | return this.httpClient.get(API_BASE_URL + '/block/' + hash); 19 | } 20 | 21 | listBlocks$(height?: number): Observable { 22 | return this.httpClient.get(API_BASE_URL + '/blocks/' + (height || '')); 23 | } 24 | 25 | getTransaction$(txId: string): Observable { 26 | return this.httpClient.get(API_BASE_URL + '/tx/' + txId); 27 | } 28 | 29 | getRecentTransaction$(): Observable { 30 | return this.httpClient.get(API_BASE_URL + '/mempool/recent'); 31 | } 32 | 33 | getOutspend$(hash: string, vout: number): Observable { 34 | return this.httpClient.get(API_BASE_URL + '/tx/' + hash + '/outspend/' + vout); 35 | } 36 | 37 | getOutspends$(hash: string): Observable { 38 | return this.httpClient.get(API_BASE_URL + '/tx/' + hash + '/outspends'); 39 | } 40 | 41 | getBlockTransactions$(hash: string, index: number = 0): Observable { 42 | return this.httpClient.get(API_BASE_URL + '/block/' + hash + '/txs/' + index); 43 | } 44 | 45 | getAddress$(address: string): Observable
{ 46 | return this.httpClient.get
(API_BASE_URL + '/address/' + address); 47 | } 48 | 49 | getAddressTransactions$(address: string): Observable { 50 | return this.httpClient.get(API_BASE_URL + '/address/' + address + '/txs'); 51 | } 52 | 53 | getAddressTransactionsFromHash$(address: string, txid: string): Observable { 54 | return this.httpClient.get(API_BASE_URL + '/address/' + address + '/txs/chain/' + txid); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/app/services/state.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ReplaySubject, BehaviorSubject, Subject } from 'rxjs'; 3 | import { Block, Transaction } from '../interfaces/electrs.interface'; 4 | import { MempoolBlock, MemPoolState } from '../interfaces/websocket.interface'; 5 | import { OptimizedMempoolStats } from '../interfaces/node-api.interface'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class StateService { 11 | latestBlockHeight = 0; 12 | blocks$ = new ReplaySubject(8); 13 | conversions$ = new ReplaySubject(1); 14 | mempoolStats$ = new ReplaySubject(); 15 | mempoolBlocks$ = new ReplaySubject(1); 16 | txConfirmed$ = new Subject(); 17 | mempoolTransactions$ = new Subject(); 18 | blockTransactions$ = new Subject(); 19 | 20 | live2Chart$ = new Subject(); 21 | 22 | viewFiat$ = new BehaviorSubject(false); 23 | isOffline$ = new BehaviorSubject(false); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/app/services/websocket.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; 3 | import { WebsocketResponse } from '../interfaces/websocket.interface'; 4 | import { retryWhen, tap, delay } from 'rxjs/operators'; 5 | import { StateService } from './state.service'; 6 | import { Block, Transaction } from '../interfaces/electrs.interface'; 7 | 8 | const WEB_SOCKET_PROTOCOL = (document.location.protocol === 'https:') ? 'wss:' : 'ws:'; 9 | const WEB_SOCKET_URL = WEB_SOCKET_PROTOCOL + '//' + document.location.hostname + ':8999'; 10 | 11 | @Injectable({ 12 | providedIn: 'root' 13 | }) 14 | export class WebsocketService { 15 | private websocketSubject: WebSocketSubject = webSocket(WEB_SOCKET_URL); 16 | private goneOffline = false; 17 | private lastWant: string[] | null = null; 18 | private trackingTxId: string | null = null; 19 | private trackingAddress: string | null = null; 20 | 21 | constructor( 22 | private stateService: StateService, 23 | ) { 24 | this.startSubscription(); 25 | } 26 | 27 | startSubscription() { 28 | this.websocketSubject.next({'action': 'init'}); 29 | this.websocketSubject 30 | .pipe( 31 | retryWhen((errors: any) => errors 32 | .pipe( 33 | tap(() => { 34 | this.goneOffline = true; 35 | this.websocketSubject.next({'action': 'init'}); 36 | this.stateService.isOffline$.next(true); 37 | }), 38 | delay(5000), 39 | ) 40 | ), 41 | ) 42 | .subscribe((response: WebsocketResponse) => { 43 | if (response.blocks && response.blocks.length) { 44 | const blocks = response.blocks; 45 | blocks.forEach((block: Block) => { 46 | if (block.height > this.stateService.latestBlockHeight) { 47 | this.stateService.latestBlockHeight = block.height; 48 | this.stateService.blocks$.next(block); 49 | } 50 | }); 51 | } 52 | 53 | if (response.block) { 54 | if (response.block.height > this.stateService.latestBlockHeight) { 55 | this.stateService.latestBlockHeight = response.block.height; 56 | this.stateService.blocks$.next(response.block); 57 | } 58 | 59 | if (response.txConfirmed) { 60 | this.trackingTxId = null; 61 | this.stateService.txConfirmed$.next(response.block); 62 | } 63 | } 64 | 65 | if (response.conversions) { 66 | this.stateService.conversions$.next(response.conversions); 67 | } 68 | 69 | if (response['mempool-blocks']) { 70 | this.stateService.mempoolBlocks$.next(response['mempool-blocks']); 71 | } 72 | 73 | if (response['address-transactions']) { 74 | response['address-transactions'].forEach((addressTransaction: Transaction) => { 75 | this.stateService.mempoolTransactions$.next(addressTransaction); 76 | }); 77 | } 78 | 79 | if (response['address-block-transactions']) { 80 | response['address-block-transactions'].forEach((addressTransaction: Transaction) => { 81 | this.stateService.blockTransactions$.next(addressTransaction); 82 | }); 83 | } 84 | 85 | if (response['live-2h-chart']) { 86 | this.stateService.live2Chart$.next(response['live-2h-chart']); 87 | } 88 | 89 | if (response.mempoolInfo) { 90 | this.stateService.mempoolStats$.next({ 91 | memPoolInfo: response.mempoolInfo, 92 | vBytesPerSecond: response.vBytesPerSecond, 93 | }); 94 | } 95 | 96 | if (this.goneOffline === true) { 97 | this.goneOffline = false; 98 | if (this.lastWant) { 99 | this.want(this.lastWant); 100 | } 101 | if (this.trackingTxId) { 102 | this.startTrackTransaction(this.trackingTxId); 103 | } 104 | if (this.trackingAddress) { 105 | this.startTrackTransaction(this.trackingAddress); 106 | } 107 | this.stateService.isOffline$.next(false); 108 | } 109 | }, 110 | (err: Error) => { 111 | console.log(err); 112 | this.goneOffline = true; 113 | console.log('Error, retrying in 10 sec'); 114 | window.setTimeout(() => this.startSubscription(), 10000); 115 | }); 116 | } 117 | 118 | startTrackTransaction(txId: string) { 119 | this.websocketSubject.next({ 'track-tx': txId }); 120 | this.trackingTxId = txId; 121 | } 122 | 123 | startTrackAddress(address: string) { 124 | this.websocketSubject.next({ 'track-address': address }); 125 | this.trackingAddress = address; 126 | } 127 | 128 | fetchStatistics(historicalDate: string) { 129 | this.websocketSubject.next({ historicalDate }); 130 | } 131 | 132 | want(data: string[]) { 133 | this.websocketSubject.next({action: 'want', data: data}); 134 | this.lastWant = data; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/.gitkeep -------------------------------------------------------------------------------- /frontend/src/assets/arrowdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/arrowdown.png -------------------------------------------------------------------------------- /frontend/src/assets/bitcoin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/bitcoin-logo.png -------------------------------------------------------------------------------- /frontend/src/assets/btc-qr-code-segwit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/btc-qr-code-segwit.png -------------------------------------------------------------------------------- /frontend/src/assets/btc-qr-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/btc-qr-code.png -------------------------------------------------------------------------------- /frontend/src/assets/clippy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/divider-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/divider-new.png -------------------------------------------------------------------------------- /frontend/src/assets/expand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/expand.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-144x144.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-192x192.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-36x36.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-48x48.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-72x72.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/android-icon-96x96.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-114x114.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-120x120.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-144x144.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-152x152.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-180x180.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-57x57.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-60x60.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-72x72.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-76x76.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/apple-icon.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /frontend/src/assets/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/favicons/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /frontend/src/assets/favicons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/ms-icon-144x144.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/ms-icon-150x150.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/ms-icon-310x310.png -------------------------------------------------------------------------------- /frontend/src/assets/favicons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/favicons/ms-icon-70x70.png -------------------------------------------------------------------------------- /frontend/src/assets/mempool-space-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/mempool-space-logo.png -------------------------------------------------------------------------------- /frontend/src/assets/mempool-tube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/mempool-tube.png -------------------------------------------------------------------------------- /frontend/src/assets/paynym-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/assets/paynym-code.png -------------------------------------------------------------------------------- /frontend/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz/mempool-space-explorer/c0a3753d7968fc0edb4ca802be21bf3c865d4bf7/frontend/src/favicon.ico -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mempool.space - Bitcoin blockchain explorer and mempool visualizer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /frontend/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. 3 | */ 4 | import '@angular/localize/init'; 5 | /** 6 | * This file includes polyfills needed by Angular and is loaded before the app. 7 | * You can add your own extra polyfills to this file. 8 | * 9 | * This file is divided into 2 sections: 10 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 11 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 12 | * file. 13 | * 14 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 15 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 16 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 17 | * 18 | * Learn more in https://angular.io/guide/browser-support 19 | */ 20 | 21 | /*************************************************************************************************** 22 | * BROWSER POLYFILLS 23 | */ 24 | 25 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 26 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 27 | 28 | /** 29 | * Web Animations `@angular/platform-browser/animations` 30 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 31 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 32 | */ 33 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 34 | 35 | /** 36 | * By default, zone.js will patch all possible macroTask and DomEvents 37 | * user can disable parts of macroTask/DomEvents patch by setting following flags 38 | * because those flags need to be set before `zone.js` being loaded, and webpack 39 | * will put import in the top of bundle, so user need to create a separate file 40 | * in this directory (for example: zone-flags.ts), and put the following flags 41 | * into that file, and then add the following code before importing zone.js. 42 | * import './zone-flags.ts'; 43 | * 44 | * The flags allowed in zone-flags.ts are listed here. 45 | * 46 | * The following flags will work for all browsers. 47 | * 48 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 49 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 50 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 51 | * 52 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 53 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 54 | * 55 | * (window as any).__Zone_enable_cross_context_check = true; 56 | * 57 | */ 58 | 59 | /*************************************************************************************************** 60 | * Zone JS is required by default for Angular itself. 61 | */ 62 | import 'zone.js/dist/zone'; // Included with Angular CLI. 63 | 64 | 65 | /*************************************************************************************************** 66 | * APPLICATION IMPORTS 67 | */ 68 | 69 | (window as any).global = window; 70 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* Bootstrap */ 2 | 3 | $body-bg: #1d1f31; 4 | $body-color: #fff; 5 | $gray-800: #1d1f31; 6 | $gray-700: #fff; 7 | 8 | $nav-tabs-link-active-bg: #11131f; 9 | 10 | $primary: #2b89c7; 11 | $secondary: #2d3348; 12 | 13 | $link-color: #1bd8f4; 14 | $link-decoration: none !default; 15 | $link-hover-color: darken($link-color, 15%) !default; 16 | $link-hover-decoration: underline !default; 17 | 18 | @import "~bootstrap/scss/bootstrap"; 19 | @import '~tlite/tlite.css'; 20 | 21 | html, body { 22 | height: 100%; 23 | overflow-x: hidden; 24 | } 25 | 26 | body { 27 | background-color: #11131f; 28 | } 29 | 30 | .container { 31 | position: relative; 32 | } 33 | 34 | :focus { 35 | outline: none !important; 36 | } 37 | 38 | .box { 39 | min-height: 1px; 40 | padding: 1.25rem; 41 | position: relative; 42 | display: -webkit-box; 43 | -webkit-box-orient: vertical; 44 | -webkit-box-direction: normal; 45 | min-width: 0; 46 | word-wrap: break-word; 47 | background-color: #24273e; 48 | background-clip: border-box; 49 | border: 1px solid rgba(0,0,0,.125); 50 | box-shadow: 0.125rem 0.125rem 0.25rem rgba(0,0,0,0.075); 51 | } 52 | 53 | .form-control { 54 | color: #495057; 55 | } 56 | .form-control:focus { 57 | color: #000; 58 | } 59 | 60 | .h2-match-table { 61 | padding-left: .65rem; 62 | } 63 | 64 | .skeleton-loader { 65 | box-sizing: border-box; 66 | /** 67 | * `overflow` and `position` are required steps to make sure 68 | * the component respects the specified dimensions 69 | * given via `theme` object @Input attribute 70 | */ 71 | overflow: hidden; 72 | position: relative; 73 | 74 | animation: progress 2s ease-in-out infinite; 75 | background: #2e324e no-repeat; 76 | background-image: linear-gradient( 77 | 90deg, 78 | rgba(255, 255, 255, 0), 79 | #5d6182, 80 | rgba(255, 255, 255, 0) 81 | ); 82 | background-size: 200px 100%; 83 | border-radius: 4px; 84 | width: 100%; 85 | height: 14px; 86 | display: inline-block; 87 | 88 | &:after, 89 | &:before { 90 | box-sizing: border-box; 91 | } 92 | 93 | &.circle { 94 | width: 40px; 95 | height: 40px; 96 | margin: 5px; 97 | border-radius: 50%; 98 | } 99 | } 100 | 101 | @keyframes progress { 102 | 0% { 103 | background-position: -200px 0; 104 | } 105 | 100% { 106 | background-position: calc(200px + 100%) 0; 107 | } 108 | } 109 | 110 | .smaller-text { 111 | font-size: 14px; 112 | } 113 | 114 | .nowrap { 115 | white-space: nowrap; 116 | } 117 | 118 | .table-xs th, .table-xs td { 119 | padding: 0.1rem; 120 | } 121 | 122 | .table { 123 | margin-bottom: 0; 124 | } 125 | 126 | 127 | .close { 128 | color: #fff; 129 | } 130 | 131 | .close:hover { 132 | color: #fff; 133 | } 134 | 135 | .green-color { 136 | color: #3bcc49; 137 | } 138 | 139 | .yellow-color { 140 | color: #ffd800; 141 | } 142 | 143 | .table-striped tbody tr:nth-of-type(odd) { 144 | background-color: #181b2d !important; 145 | } 146 | 147 | .bordertop { 148 | border-top: 1px solid #4c4c4c; 149 | } 150 | 151 | .smaller-text { 152 | font-size: 14px; 153 | } 154 | 155 | 156 | /* Chartist */ 157 | $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); 158 | $ct-series-colors: ( 159 | #D81B60, 160 | #8E24AA, 161 | #5E35B1, 162 | #3949AB, 163 | #1E88E5, 164 | #039BE5, 165 | #00ACC1, 166 | #00897B, 167 | #43A047, 168 | #7CB342, 169 | #C0CA33, 170 | #FDD835, 171 | #FFB300, 172 | #FB8C00, 173 | #F4511E, 174 | #6D4C41, 175 | #757575, 176 | #546E7A, 177 | #b71c1c, 178 | #880E4F, 179 | #4A148C, 180 | #311B92, 181 | #1A237E, 182 | #0D47A1, 183 | #01579B, 184 | #006064, 185 | #004D40, 186 | #1B5E20, 187 | #33691E, 188 | #827717, 189 | #F57F17, 190 | #FF6F00, 191 | #E65100, 192 | #BF360C, 193 | #3E2723, 194 | #212121, 195 | #263238, 196 | #a748ca, 197 | #6188e2, 198 | #a748ca, 199 | #6188e2, 200 | ); 201 | 202 | @import "../node_modules/chartist/dist/scss/chartist.scss"; 203 | 204 | .ct-bar-label { 205 | font-size: 20px; 206 | font-weight: bold; 207 | fill: #fff; 208 | } 209 | 210 | .ct-target-line { 211 | stroke: #f5f5f5; 212 | stroke-width: 3px; 213 | stroke-dasharray: 7px; 214 | } 215 | 216 | .ct-area { 217 | stroke: none; 218 | fill-opacity: 0.9; 219 | } 220 | 221 | .ct-label { 222 | fill: rgba(255, 255, 255, 0.4); 223 | color: rgba(255, 255, 255, 0.4); 224 | } 225 | 226 | .ct-grid { 227 | stroke: rgba(255, 255, 255, 0.2); 228 | } 229 | 230 | /* LEGEND */ 231 | 232 | .ct-legend { 233 | position: absolute; 234 | z-index: 10; 235 | left: 0px; 236 | list-style: none; 237 | font-size: 13px; 238 | padding: 0px 0px 0px 30px; 239 | top: 90px; 240 | 241 | li { 242 | position: relative; 243 | padding-left: 23px; 244 | margin-bottom: 0px; 245 | } 246 | 247 | li:before { 248 | width: 12px; 249 | height: 12px; 250 | position: absolute; 251 | left: 0; 252 | content: ''; 253 | border: 3px solid transparent; 254 | border-radius: 2px; 255 | } 256 | 257 | li.inactive:before { 258 | background: transparent; 259 | } 260 | 261 | &.ct-legend-inside { 262 | position: absolute; 263 | top: 0; 264 | right: 0; 265 | } 266 | 267 | @for $i from 0 to length($ct-series-colors) { 268 | .ct-series-#{$i}:before { 269 | background-color: nth($ct-series-colors, $i + 1); 270 | border-color: nth($ct-series-colors, $i + 1); 271 | } 272 | } 273 | } 274 | 275 | 276 | .chartist-tooltip { 277 | position: absolute; 278 | display: inline-block; 279 | opacity: 0; 280 | min-width: 5em; 281 | padding: .5em; 282 | background: #F4C63D; 283 | color: #453D3F; 284 | font-family: Oxygen,Helvetica,Arial,sans-serif; 285 | font-weight: 700; 286 | text-align: center; 287 | pointer-events: none; 288 | z-index: 1; 289 | -webkit-transition: opacity .2s linear; 290 | -moz-transition: opacity .2s linear; 291 | -o-transition: opacity .2s linear; 292 | transition: opacity .2s linear; } 293 | .chartist-tooltip:before { 294 | content: ""; 295 | position: absolute; 296 | top: 100%; 297 | left: 50%; 298 | width: 0; 299 | height: 0; 300 | margin-left: -15px; 301 | border: 15px solid transparent; 302 | border-top-color: #F4C63D; } 303 | .chartist-tooltip.tooltip-show { 304 | opacity: 1; } 305 | 306 | .ct-area, .ct-line { 307 | pointer-events: none; } 308 | 309 | .ct-bar { 310 | stroke-width: 1px; 311 | } 312 | 313 | hr { 314 | border-top: 1px solid rgba(255, 255, 255, 0.1); 315 | } 316 | 317 | -------------------------------------------------------------------------------- /frontend/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | }, 22 | "angularCompilerOptions": { 23 | "fullTemplateTypeCheck": true, 24 | "strictInjectionParameters": true, 25 | "strictTemplates": true, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warning" 8 | }, 9 | "component-class-suffix": true, 10 | "contextual-lifecycle": true, 11 | "directive-class-suffix": true, 12 | "object-literal-shorthand": false, 13 | "directive-selector": [ 14 | true, 15 | "attribute", 16 | "app", 17 | "camelCase" 18 | ], 19 | "component-selector": [ 20 | true, 21 | "element", 22 | "app", 23 | "kebab-case" 24 | ], 25 | "import-blacklist": [ 26 | true, 27 | "rxjs/Rx" 28 | ], 29 | "interface-name": false, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-access": false, 36 | "member-ordering": [ 37 | true, 38 | { 39 | "order": [ 40 | "static-field", 41 | "instance-field", 42 | "static-method", 43 | "instance-method" 44 | ] 45 | } 46 | ], 47 | "no-consecutive-blank-lines": false, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-empty": false, 57 | "no-inferrable-types": [ 58 | true, 59 | "ignore-params" 60 | ], 61 | "no-non-null-assertion": true, 62 | "no-redundant-jsdoc": true, 63 | "no-switch-case-fall-through": true, 64 | "no-use-before-declare": true, 65 | "no-var-requires": false, 66 | "object-literal-key-quotes": [ 67 | false, 68 | "as-needed" 69 | ], 70 | "object-literal-sort-keys": false, 71 | "ordered-imports": false, 72 | "quotemark": [ 73 | true, 74 | "single" 75 | ], 76 | "trailing-comma": false, 77 | "no-conflicting-lifecycle": true, 78 | "no-host-metadata-property": true, 79 | "no-input-rename": true, 80 | "no-inputs-metadata-property": true, 81 | "no-output-native": true, 82 | "no-output-on-prefix": true, 83 | "no-output-rename": true, 84 | "no-outputs-metadata-property": true, 85 | "template-banana-in-box": true, 86 | "template-no-negated-async": true, 87 | "use-lifecycle-interface": true, 88 | "use-pipe-transform-interface": true 89 | }, 90 | "rulesDirectory": [ 91 | "codelyzer" 92 | ] 93 | } --------------------------------------------------------------------------------