├── .env-sample ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── CHANGELOG-API.md ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── app.js ├── app ├── actionPerformanceMonitor.js ├── api │ ├── addressApi.js │ ├── blockchainAddressApi.js │ ├── blockchairAddressApi.js │ ├── blockcypherAddressApi.js │ ├── coreApi.js │ ├── electrumAddressApi.js │ ├── mockApi.js │ ├── rpcApi.js │ └── xyzpubApi.js ├── appStats.js ├── auth.js ├── cacheUtils.js ├── coins.js ├── coins │ ├── btc.js │ ├── btcFun.js │ ├── btcHolidays.js │ └── btcQuotes.js ├── config.js ├── credentials.js ├── currencies.js ├── normalizeActions.js ├── redisCache.js ├── resourceIntegrityHashes.js ├── sso.js ├── statTracker.js ├── systemMonitor.js └── utils.js ├── bin ├── cli.js ├── frontend-resource-integrity.js ├── refresh-mining-pool-configs.js ├── test.js └── www ├── docker-compose.yml ├── docs ├── Server-Setup-Docker.md ├── Server-Setup.md ├── api.js ├── btc-explorer.com.conf ├── explorer.btc21.org.conf └── nginx-reverse-proxy.md ├── npm-shrinkwrap.json ├── package.json ├── pnpm-lock.yaml ├── public ├── .well-known │ └── acme-challenge │ │ └── certbot-challenges-here ├── audio │ └── 609335__kenneth_cooney__levelup.wav ├── favicon.ico ├── font │ ├── Source_Code_Pro │ │ ├── SourceCodePro-Bold.woff2 │ │ └── SourceCodePro-Regular.woff2 │ ├── Ubuntu │ │ ├── UFL.txt │ │ ├── Ubuntu-Bold.woff2 │ │ ├── Ubuntu-Light.woff2 │ │ └── Ubuntu-Regular.woff2 │ ├── bootstrap-icons.woff │ └── bootstrap-icons.woff2 ├── img │ ├── logo │ │ ├── btc.png │ │ ├── logo-with-background.svg │ │ └── logo.svg │ ├── network-mainnet │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── coin-icon.svg │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── icon.svg │ │ ├── logo-with-background.svg │ │ ├── logo.svg │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── network-regtest │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── coin-icon.svg │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── icon.svg │ │ ├── logo.svg │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── network-signet │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── coin-icon.svg │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── icon.svg │ │ ├── logo.svg │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── network-testnet │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── coin-icon.svg │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── icon.svg │ │ ├── logo.svg │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── preview.png │ ├── qr-btc.png │ └── screenshots │ │ ├── block.png │ │ ├── blocks.png │ │ ├── homepage.png │ │ ├── mempool-summary.png │ │ ├── node-details.png │ │ ├── rpc-browser.png │ │ ├── transaction-raw.png │ │ └── transaction.png ├── js │ ├── bootstrap.bundle.min.js │ ├── chart.min.js │ ├── chartjs-adapter-moment.min.js │ ├── dataTables.bootstrap4.min.js │ ├── decimal.js │ ├── highlight.min.js │ ├── jquery.dataTables.min.js │ ├── jquery.min.js │ ├── moment.min.js │ ├── sentry.min.js │ └── site.js ├── leaflet │ ├── images │ │ ├── layers-2x.png │ │ ├── layers.png │ │ ├── marker-icon-2x.png │ │ ├── marker-icon.png │ │ └── marker-shadow.png │ ├── leaflet.css │ └── leaflet.js ├── robots.txt ├── scss │ ├── dark-v1.scss │ ├── dark.scss │ ├── light.scss │ └── main.scss ├── style │ ├── bootstrap-icons.css │ ├── dark-v1.min.css │ ├── dark.min.css │ ├── dataTables.bootstrap4.min.css │ ├── highlight.min.css │ └── light.min.css └── txt │ └── mining-pools-configs │ └── BTC │ ├── 0.json │ ├── 1.json │ ├── 2.json │ └── 3.json ├── raw └── full-indicator.psd ├── roadmap.md ├── routes ├── adminRouter.js ├── apiRouter.js ├── baseRouter.js ├── cleanupRouter.js ├── internalApiRouter.js ├── snippetRouter.js └── testRouter.js └── views ├── about.pug ├── address.pug ├── admin ├── admin-mixins.pug ├── app-stats.pug ├── dashboard.pug ├── os-stats.pug └── perf-log.pug ├── api-changelog.pug ├── api-docs.pug ├── bitcoin-whitepaper.pug ├── block-analysis-search.pug ├── block-analysis.pug ├── block-stats.pug ├── block.pug ├── blocks.pug ├── changelog.pug ├── connect.pug ├── difficulty-history.pug ├── error.pug ├── extended-public-key.pug ├── fun.pug ├── holidays.pug ├── includes ├── blocks-list.pug ├── debug-overrides.pug ├── electrum-trust-note.pug ├── index-network-summary.pug ├── line-graph.pug ├── page-errors-modal.pug ├── shared-mixins.pug ├── tools-card-block.pug ├── tools-card.pug ├── tx-mixins.pug └── value-display.pug ├── index.pug ├── layout-iframe.pug ├── layout.pug ├── mempool-summary.pug ├── mempool-transactions.pug ├── mining-summary.pug ├── next-block.pug ├── next-halving.pug ├── node-details.pug ├── peers.pug ├── predicted-blocks.pug ├── projected-blocks-old.pug ├── quote.pug ├── quotes.pug ├── rpc-browser.pug ├── rpc-terminal.pug ├── search.pug ├── snippets ├── index-halving-countdown.pug ├── index-next-block.pug ├── quote.pug ├── timestamp.pug ├── tz-update-toast.pug └── utxo-set.pug ├── terminal.pug ├── test └── tx-display.pug ├── tools.pug ├── transaction.pug ├── tx-stats.pug ├── user-settings.pug └── utxo-set.pug /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "overrides": [ 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | }, 13 | "rules": { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/dealing-with-line-endings/ 2 | * text=auto 3 | *.css text eol=lf 4 | *.js text eol=lf 5 | 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | **Environment (please complete the following information):** 15 | 16 | - Bitcoin Core / Node Version [e.g. 0.16.3] 17 | - NodeJS Version [e.g. 9.x] 18 | - Browser [e.g. chrome, safari] 19 | - Code Version / Commit [e.g. ab6cde8] 20 | - Installation Method [e.g. "npm" or "source code"] 21 | 22 | **Configuration file content** 23 | 24 | Please include the content from the following files. **BE SURE TO MODIFY YOUR CREDENTIALS BEFORE SUBMITTING!!!** 25 | - bitcoin.conf 26 | - Your btc-rpc-explorer environment configuration (either `$WORKING_DIR/.env` or `~/.config/btc-rpc-explorer.env`) 27 | 28 | **To Reproduce** 29 | 30 | Steps to reproduce the behavior: 31 | 1. Go to '...' 32 | 2. Click on '....' 33 | 3. Scroll down to '....' 34 | 4. See error 35 | 36 | **Screenshots or Log Output** 37 | 38 | If applicable, add screenshots or log output to help explain your problem. 39 | 40 | **Additional context** 41 | 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a new feature/enhancement be added 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the new feature or enhancement** 11 | 12 | A clear description of the new feature or enhancement. Perhaps a description of how it functions, how it looks, where it "lives", etc. 13 | 14 | 15 | **Additional context** 16 | 17 | Add any other context about the value of the new feature. 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 21 * * 4' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # cache dir 61 | cache/ 62 | 63 | *.css.map 64 | -------------------------------------------------------------------------------- /CHANGELOG-API.md: -------------------------------------------------------------------------------- 1 | This changelog specifically tracks changes to the Public API available at `/api` and is maintained separately from the app CHANGELOG such that it can properly adhere to semantic versioning. 2 | 3 | ##### v2.1.0 4 | ###### Unreleased 5 | 6 | * Changed `/api/mempool/fees` to include more details pertaining to `nextBlock` (nextBlock.smart is where the previous "nextBlock" scalar value used to be) 7 | 8 | ##### v2.0.0 9 | ###### 2023-06-14 10 | 11 | * BREAKING: All actions now return JSON content 12 | * Added: 13 | * `/api/blocks/tip` (replaces `/api/blocks/tip/hash` and `/api/blocks/tip/height`) 14 | * `/api/xyzpub/txids/$XPUB` 15 | * `/api/xyzpub/addresses/$XPUB` 16 | * `/api/block/header/$HEIGHT` 17 | * `/api/block/header/$HASH` 18 | * `/api/blockchain/next-halving` 19 | * `/api/holidays/all` 20 | * `/api/holidays/today` 21 | * `/api/holidays/$DAY` 22 | * `/api/tx/volume/24h` 23 | * `/api/price/marketcap` (replaces `/api/price/$CURRENCY/marketcap`) 24 | * `/api/price/sats` (replaces `/api/price/$CURRENCY/sats`) 25 | * Changed output: 26 | * `/api/tx/$TXID` 27 | * Added result.vin[i].scriptSig.address 28 | * Added result.vin[i].scriptSig.type 29 | * Added result.fee, including result.fee.amount and result.fee.unit 30 | * Added result.fun, when applicable, which includes special details about the tx 31 | * `/api/price[/...]` 32 | * Return values exclude thousands separators by default; they can be added with "?format=true" 33 | * Changed path: 34 | * `/api/util/xyzpub/$XPUB` -> `/api/xyzpub/$XPUB` (auto-redirect included) 35 | * Removed: 36 | * `/api/blocks/tip/hash` (see `/api/blocks/tip`) 37 | * `/api/blocks/tip/height` (see `/api/blocks/tip`) 38 | * `/api/mempool/count` (see "size" field in output from `/api/mempool/summary`) 39 | * `/api/price/$CURRENCY/marketcap` (see individual fields in output from `/api/price/marketcap`) 40 | * `/api/price/$CURRENCY/sats` (see individual fields in output from `/api/price/sats`) 41 | 42 | 43 | 44 | ##### v1.1.0 45 | ###### 2021-12-07 46 | 47 | * Added: 48 | * `/api/blockchain/utxo-set` 49 | * `/api/address/$ADDRESS` 50 | * `/api/mining/next-block` 51 | * `/api/mining/next-block/txids` 52 | * `/api/mining/next-block/includes/$TXID` 53 | * `/api/mining/miner-summary` 54 | 55 | 56 | 57 | ##### v1.0.0 58 | ###### 2021-08-10 59 | 60 | * Initial release 61 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 as builder 2 | WORKDIR /workspace 3 | COPY . . 4 | RUN npm install 5 | 6 | FROM node:20-alpine 7 | WORKDIR /workspace 8 | COPY --from=builder /workspace . 9 | RUN apk --update add git 10 | CMD npm start 11 | EXPOSE 3002 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2020 Dan Janosik 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 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * moment -> luxon :/ 2 | * value-validation in /changeSetting (like is currently done for userTzOffset) 3 | 4 | * 24hr comparisons for exchange rate/gold rate? 5 | * /mempool-summary: more granular fee rate bar chart; add line graph on top of bar chart for cumulative blocks 6 | * incoming tx page (live) 7 | * electron: https://gist.github.com/maximilian-lindsey/a446a7ee87838a62099d 8 | 9 | * use flex utilities for "summary rows"? -------------------------------------------------------------------------------- /app/actionPerformanceMonitor.js: -------------------------------------------------------------------------------- 1 | const onHeaders = require('on-headers'); 2 | const debug = require("debug"); 3 | const debugLog = debug("monitor"); 4 | const utils = require("./utils.js"); 5 | 6 | 7 | const onHeadersListener = (config, req, statusCode, startTimeNanos, statTracker) => { 8 | try { 9 | const responseTimeNanos = process.hrtime.bigint() - startTimeNanos; 10 | const responseTimeMillis = parseInt(responseTimeNanos) * 1e-6; 11 | 12 | const category = Math.floor(statusCode / 100); 13 | 14 | 15 | let action = req.baseUrl + req.path; 16 | 17 | if (config.ignoredEndsWithActionsRegex.test(action)) { 18 | return; 19 | } 20 | 21 | if (config.ignoredStartsWithActionsRegex.test(action)) { 22 | return; 23 | } 24 | 25 | let allActions = "*"; 26 | if (config.normalizeAction) { 27 | action = config.normalizeAction(action); 28 | allActions = config.normalizeAction(allActions); 29 | } 30 | 31 | statTracker.trackPerformance(`action.${action}`, responseTimeMillis); 32 | statTracker.trackPerformance("action.*", responseTimeMillis); 33 | 34 | statTracker.trackEvent(`action-status.${action}.${category}00`); 35 | statTracker.trackEvent(`action-status.*.${category}00`); 36 | 37 | var userAgent = req.headers['user-agent']; 38 | var crawler = utils.getCrawlerFromUserAgentString(userAgent); 39 | if (crawler) { 40 | statTracker.trackEvent(`site-crawl.${crawler}`); 41 | } 42 | 43 | } catch (err) { 44 | debugLog(err); 45 | } 46 | }; 47 | 48 | const validateConfig = (cfg) => { 49 | const config = (cfg || {}); 50 | 51 | if (!config.ignoredEndsWithActions) { 52 | config.ignoredEndsWithActions = /\.js|\.css|\.svg|\.png/; 53 | } 54 | 55 | config.ignoredEndsWithActionsRegex = new RegExp(config.ignoredEndsWithActions + "$", "i"); 56 | 57 | 58 | if (!config.ignoredStartsWithActions) { 59 | config.ignoredStartsWithActions = "ignoreStartsWithThis|andIgnoreStartsWithThis"; 60 | } 61 | 62 | config.ignoredStartsWithActionsRegex = new RegExp("^" + config.ignoredStartsWithActions, "i"); 63 | 64 | return config; 65 | }; 66 | 67 | const middlewareWrapper = (statTracker, cfg) => { 68 | const config = validateConfig(cfg); 69 | 70 | const middleware = (req, res, next) => { 71 | const startTimeNanos = process.hrtime.bigint(); 72 | 73 | onHeaders(res, () => { 74 | onHeadersListener(config, req, res.statusCode, startTimeNanos, statTracker); 75 | }); 76 | 77 | next(); 78 | }; 79 | 80 | middleware.middleware = middleware; 81 | 82 | return middleware; 83 | }; 84 | 85 | module.exports = middlewareWrapper; 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/api/addressApi.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const config = require("./../config.js"); 4 | const coins = require("../coins.js"); 5 | const utils = require("../utils.js"); 6 | 7 | const electrumAddressApi = require("./electrumAddressApi.js"); 8 | const blockchainAddressApi = require("./blockchainAddressApi.js"); 9 | const blockchairAddressApi = require("./blockchairAddressApi.js"); 10 | const blockcypherAddressApi = require("./blockcypherAddressApi.js"); 11 | 12 | function getSupportedAddressApis() { 13 | return ["blockchain.com", "blockchair.com", "blockcypher.com", "electrum", "electrumx"]; 14 | } 15 | 16 | function getCurrentAddressApiFeatureSupport() { 17 | if (config.addressApi == "blockchain.com") { 18 | return { 19 | pageNumbers: true, 20 | sortDesc: true, 21 | sortAsc: true 22 | }; 23 | 24 | } else if (config.addressApi == "blockchair.com") { 25 | return { 26 | pageNumbers: true, 27 | sortDesc: true, 28 | sortAsc: false 29 | }; 30 | 31 | } else if (config.addressApi == "blockcypher.com") { 32 | return { 33 | pageNumbers: true, 34 | sortDesc: true, 35 | sortAsc: false 36 | }; 37 | 38 | } else if (config.addressApi == "electrum" || config.addressApi == "electrumx") { 39 | return { 40 | pageNumbers: true, 41 | sortDesc: true, 42 | sortAsc: true 43 | }; 44 | } 45 | } 46 | 47 | function getAddressDetails(address, scriptPubkey, sort, limit, offset) { 48 | return new Promise(function(resolve, reject) { 49 | var promises = []; 50 | 51 | if (config.addressApi == "blockchain.com") { 52 | promises.push(blockchainAddressApi.getAddressDetails(address, scriptPubkey, sort, limit, offset)); 53 | 54 | } else if (config.addressApi == "blockchair.com") { 55 | promises.push(blockchairAddressApi.getAddressDetails(address, scriptPubkey, sort, limit, offset)); 56 | 57 | } else if (config.addressApi == "blockcypher.com") { 58 | promises.push(blockcypherAddressApi.getAddressDetails(address, scriptPubkey, sort, limit, offset)); 59 | 60 | } else if (config.addressApi == "electrum" || config.addressApi == "electrumx") { 61 | promises.push(electrumAddressApi.getAddressDetails(address, scriptPubkey, sort, limit, offset)); 62 | 63 | } else { 64 | promises.push(new Promise(function(resolve, reject) { 65 | resolve({addressDetails:null, errors:["No address API configured"]}); 66 | })); 67 | } 68 | 69 | Promise.all(promises).then(function(results) { 70 | if (results && results.length > 0) { 71 | resolve(results[0]); 72 | 73 | } else { 74 | resolve(null); 75 | } 76 | }).catch(function(err) { 77 | reject(err); 78 | }); 79 | }); 80 | } 81 | 82 | 83 | 84 | module.exports = { 85 | getSupportedAddressApis: getSupportedAddressApis, 86 | getCurrentAddressApiFeatureSupport: getCurrentAddressApiFeatureSupport, 87 | getAddressDetails: getAddressDetails 88 | }; -------------------------------------------------------------------------------- /app/api/blockchainAddressApi.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const axios = require("axios"); 4 | const utils = require("./../utils.js"); 5 | 6 | 7 | function getAddressDetails(address, scriptPubkey, sort, limit, offset) { 8 | return new Promise(async (resolve, reject) => { 9 | if (address.startsWith("bc1")) { 10 | reject({userText:"blockchain.com API does not support bc1 (native Segwit) addresses"}); 11 | 12 | return; 13 | } 14 | 15 | if (sort == "asc") { 16 | // need to query the total number of tx first, then build paging info from that value 17 | try { 18 | const response = await axios.get( 19 | `https://blockchain.info/rawaddr/${address}?limit=1`, 20 | { headers: { 'User-Agent': 'axios' }}); 21 | 22 | var blockchainJson = response.data; 23 | 24 | var txCount = blockchainJson.n_tx; 25 | var pageCount = parseInt(txCount / limit); 26 | var lastPageSize = limit; 27 | if (pageCount * limit < txCount) { 28 | lastPageSize = txCount - pageCount * limit; 29 | } 30 | 31 | var dynamicOffset = txCount - limit - offset; 32 | if (dynamicOffset < 0) { 33 | limit += dynamicOffset; 34 | dynamicOffset += limit; 35 | } 36 | 37 | getAddressDetailsSortDesc(address, limit, dynamicOffset).then(function(result) { 38 | result.txids.reverse(); 39 | 40 | resolve({addressDetails:result}); 41 | 42 | }).catch(function(err) { 43 | utils.logError("2308hsghse", err); 44 | 45 | reject(err); 46 | }); 47 | 48 | } catch (err) { 49 | utils.logError("we0f8hasd0fhas", err); 50 | 51 | reject(fullError); 52 | } 53 | } else { 54 | getAddressDetailsSortDesc(address, limit, offset).then(function(result) { 55 | resolve({addressDetails:result}); 56 | 57 | }).catch(function(err) { 58 | utils.logError("3208hwssse", err); 59 | 60 | reject(err); 61 | }); 62 | } 63 | }); 64 | } 65 | 66 | function getAddressDetailsSortDesc(address, limit, offset) { 67 | return new Promise(async (resolve, reject) => { 68 | try { 69 | const apiResponse = await axios.get( 70 | `https://blockchain.info/rawaddr/${address}?limit=${limit}&offset=${offset}`, 71 | { headers: { 'User-Agent': 'axios' }}); 72 | 73 | var blockchainJson = apiResponse.data; 74 | 75 | var response = {}; 76 | 77 | response.txids = []; 78 | response.blockHeightsByTxid = {}; 79 | blockchainJson.txs.forEach(function(tx) { 80 | response.txids.push(tx.hash); 81 | response.blockHeightsByTxid[tx.hash] = tx.block_height; 82 | }); 83 | 84 | response.txCount = blockchainJson.n_tx; 85 | response.hash160 = blockchainJson.hash160; 86 | response.totalReceivedSat = blockchainJson.total_received; 87 | response.totalSentSat = blockchainJson.total_sent; 88 | response.balanceSat = blockchainJson.final_balance; 89 | response.source = "blockchain.com"; 90 | 91 | resolve(response); 92 | 93 | } catch (err) { 94 | utils.logError("32907shsghs", err); 95 | 96 | reject(err); 97 | } 98 | }); 99 | } 100 | 101 | 102 | module.exports = { 103 | getAddressDetails: getAddressDetails 104 | }; 105 | -------------------------------------------------------------------------------- /app/api/blockchairAddressApi.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const axios = require("axios"); 4 | const utils = require("./../utils.js"); 5 | 6 | 7 | function getAddressDetails(address, scriptPubkey, sort, limit, offset) { 8 | // Note: blockchair api seems to not respect the limit parameter, always using 100 9 | return new Promise(async (resolve, reject) => { 10 | var mainnetUrl = `https://api.blockchair.com/bitcoin/dashboards/address/${address}/?offset=${offset}`; 11 | var testnetUrl = `https://api.blockchair.com/bitcoin/testnet/dashboards/address/${address}/?offset=${offset}`; 12 | var url = (global.activeBlockchain == "main") ? mainnetUrl : ((global.activeBlockchain == "test") ? testnetUrl : mainnetUrl); 13 | 14 | var options = { 15 | url: url, 16 | headers: { 17 | 'User-Agent': 'request' 18 | } 19 | }; 20 | 21 | try { 22 | const response = await axios.get( 23 | url, 24 | { headers: { "User-Agent": "axios" }}); 25 | 26 | var responseObj = response.data; 27 | responseObj = responseObj.data[address]; 28 | 29 | var result = {}; 30 | 31 | result.txids = []; 32 | 33 | // blockchair doesn't support offset for paging, so simulate up to the hard cap of 2,000 34 | for (var i = 0; i < Math.min(responseObj.transactions.length, limit); i++) { 35 | var txid = responseObj.transactions[i]; 36 | 37 | result.txids.push(txid); 38 | } 39 | 40 | result.txCount = responseObj.address.transaction_count; 41 | result.totalReceivedSat = responseObj.address.received; 42 | result.totalSentSat = responseObj.address.spent; 43 | result.balanceSat = responseObj.address.balance; 44 | result.source = "blockchair.com"; 45 | 46 | resolve({addressDetails:result}); 47 | 48 | } catch (err) { 49 | utils.logError("308dhew3w83", err); 50 | 51 | reject(err); 52 | } 53 | }); 54 | } 55 | 56 | 57 | module.exports = { 58 | getAddressDetails: getAddressDetails 59 | }; -------------------------------------------------------------------------------- /app/api/blockcypherAddressApi.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const axios = require("axios"); 4 | const utils = require("./../utils.js"); 5 | 6 | 7 | function getAddressDetails(address, scriptPubkey, sort, limit, offset) { 8 | return new Promise(async (resolve, reject) => { 9 | if (address.startsWith("bc1")) { 10 | reject({userText:"blockcypher.com API does not support bc1 (native Segwit) addresses"}); 11 | 12 | return; 13 | } 14 | 15 | var limitOffset = limit + offset; 16 | var mainnetUrl = `https://api.blockcypher.com/v1/btc/main/addrs/${address}?limit=${limitOffset}`; 17 | var testnetUrl = `https://api.blockcypher.com/v1/btc/test3/addrs/${address}?limit=${limitOffset}`; 18 | var url = (global.activeBlockchain == "main") ? mainnetUrl : ((global.activeBlockchain == "test") ? testnetUrl : mainnetUrl); 19 | 20 | var options = { 21 | url: url, 22 | headers: { 23 | 'User-Agent': 'request' 24 | } 25 | }; 26 | 27 | try { 28 | const apiResponse = await axios.get( 29 | url, 30 | { headers: { "User-Agent": "axios" }}); 31 | 32 | var blockcypherJson = apiResponse.data; 33 | 34 | var response = {}; 35 | 36 | response.txids = []; 37 | response.blockHeightsByTxid = {}; 38 | 39 | // blockcypher doesn't support offset for paging, so simulate up to the hard cap of 2,000 40 | for (var i = offset; i < Math.min(blockcypherJson.txrefs.length, limitOffset); i++) { 41 | var tx = blockcypherJson.txrefs[i]; 42 | 43 | response.txids.push(tx.tx_hash); 44 | response.blockHeightsByTxid[tx.tx_hash] = tx.block_height; 45 | } 46 | 47 | response.txCount = blockcypherJson.n_tx; 48 | response.totalReceivedSat = blockcypherJson.total_received; 49 | response.totalSentSat = blockcypherJson.total_sent; 50 | response.balanceSat = blockcypherJson.final_balance; 51 | response.source = "blockcypher.com"; 52 | 53 | resolve({addressDetails:response}); 54 | 55 | } catch (err) { 56 | utils.logError("097wef0adsgadgs", err); 57 | 58 | reject(err); 59 | } 60 | }); 61 | } 62 | 63 | 64 | module.exports = { 65 | getAddressDetails: getAddressDetails 66 | }; -------------------------------------------------------------------------------- /app/appStats.js: -------------------------------------------------------------------------------- 1 | const statNames = [ 2 | "process.cpu", 3 | "process.mem_mb", 4 | "mem.heap.used", 5 | "mem.heap.limit", 6 | "os.loadavg.1min", 7 | "os.loadavg.5min" 8 | ]; 9 | 10 | const dataPointsToKeep = 60; 11 | const downsamplesToKeep = 72; 12 | const dataPointsPerDownsample = 6; 13 | const appStats = {}; 14 | const downsampledAppStats = {}; 15 | 16 | const trackAppStats = (name, stats) => { 17 | if (statNames.includes(name)) { 18 | if (!appStats[name]) { 19 | appStats[name] = []; 20 | downsampledAppStats[name] = []; 21 | } 22 | 23 | let dataset = appStats[name]; 24 | 25 | if (stats.max) { 26 | dataset.push({time:new Date().getTime(), value: stats.max}); 27 | } 28 | 29 | if (dataset.length > (dataPointsToKeep + dataPointsPerDownsample)) { 30 | var downsamplePoints = dataset.slice(0, dataPointsPerDownsample); 31 | var max = -Infinity; 32 | 33 | // find max of downsample 34 | downsamplePoints.forEach(x => { if (x.value > max) { max = x.value; } }); 35 | 36 | downsampledAppStats[name].push({time:downsamplePoints[0].time, value:max}); 37 | 38 | while (dataset.length > dataPointsToKeep) { 39 | dataset.shift(); 40 | } 41 | } 42 | 43 | while (downsampledAppStats[name].length > downsamplesToKeep) { 44 | downsampledAppStats[name].shift(); 45 | } 46 | } 47 | }; 48 | 49 | const getAllAppStats = () => { 50 | var allStats = {}; 51 | 52 | if (appStats[statNames[0]]) { 53 | for (var i = 0; i < statNames.length; i++) { 54 | if (downsampledAppStats[statNames[i]]) { 55 | allStats[statNames[i]] = downsampledAppStats[statNames[i]].concat(appStats[statNames[i]]); 56 | 57 | } else { 58 | allStats[statNames[i]] = appStats[statNames[i]]; 59 | } 60 | } 61 | } 62 | 63 | return allStats; 64 | }; 65 | 66 | module.exports = { 67 | trackAppStats: trackAppStats, 68 | statNames: statNames, 69 | getAllAppStats: getAllAppStats 70 | }; 71 | 72 | -------------------------------------------------------------------------------- /app/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const basicAuth = require('basic-auth'); 4 | 5 | module.exports = pass => (req, res, next) => { 6 | var cred = basicAuth(req); 7 | 8 | if (cred && cred.pass === pass) { 9 | req.authenticated = true; 10 | return next(); 11 | } 12 | 13 | res.set('WWW-Authenticate', `Basic realm="Private Area"`) 14 | .sendStatus(401); 15 | } 16 | -------------------------------------------------------------------------------- /app/cacheUtils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const debug = require("debug"); 4 | const debugLog = debug("btcexp:cache"); 5 | 6 | const utils = require("./utils.js"); 7 | 8 | 9 | const { LRUCache } = require("lru-cache"); 10 | 11 | 12 | const watchKeysRegex = /regexToMatchCacheKeysForDebugLogging/; 13 | 14 | function createMemoryLruCache(cacheName, cacheObj, onCacheEvent) { 15 | return { 16 | get: (key) => { 17 | return new Promise((resolve, reject) => { 18 | onCacheEvent("memory", "try", key); 19 | 20 | var val = cacheObj.get(key); 21 | 22 | if (val != null) { 23 | onCacheEvent("memory", "hit", key); 24 | 25 | if (key.match(watchKeysRegex)) { 26 | debugLog(`cache.${cacheName}[${key}]: HIT (${utils.addThousandsSeparators(JSON.stringify(val).length)} B)`); 27 | } 28 | } else { 29 | onCacheEvent("memory", "miss", key); 30 | 31 | if (key.match(watchKeysRegex)) { 32 | debugLog(`cache.${cacheName}[${key}]: MISS`); 33 | } 34 | } 35 | 36 | resolve(val); 37 | }); 38 | }, 39 | set: (key, obj, maxAge) => { 40 | cacheObj.set(key, obj, {ttl: maxAge}); 41 | 42 | if (key.match(watchKeysRegex)) { 43 | debugLog(`cache.${cacheName}[${key}]: SET (${utils.addThousandsSeparators(JSON.stringify(obj).length)} B), T=${maxAge}`); 44 | } 45 | 46 | onCacheEvent("memory", "set", key); 47 | }, 48 | del: (key) => { 49 | cacheObj.delete(key); 50 | 51 | onCacheEvent("memory", "del", key); 52 | 53 | if (key.match(watchKeysRegex)) { 54 | debugLog(`cache.${cacheName}[${key}]: DEL`); 55 | } 56 | } 57 | } 58 | } 59 | 60 | function tryCache(cacheKey, cacheObjs, index, resolve, reject) { 61 | if (index == cacheObjs.length) { 62 | resolve(null); 63 | 64 | return; 65 | } 66 | 67 | cacheObjs[index].get(cacheKey).then((result) => { 68 | if (result != null) { 69 | resolve(result); 70 | 71 | } else { 72 | tryCache(cacheKey, cacheObjs, index + 1, resolve, reject); 73 | } 74 | }); 75 | } 76 | 77 | function createTieredCache(cacheObjs) { 78 | return { 79 | get:(key) => { 80 | return new Promise((resolve, reject) => { 81 | tryCache(key, cacheObjs, 0, resolve, reject); 82 | }); 83 | }, 84 | set:(key, obj, maxAge) => { 85 | for (var i = 0; i < cacheObjs.length; i++) { 86 | cacheObjs[i].set(key, obj, maxAge); 87 | } 88 | } 89 | } 90 | } 91 | 92 | function lruCache(size) { 93 | return new LRUCache({ 94 | max: size 95 | }); 96 | } 97 | 98 | module.exports = { 99 | lruCache: lruCache, 100 | createMemoryLruCache: createMemoryLruCache, 101 | createTieredCache: createTieredCache 102 | } -------------------------------------------------------------------------------- /app/coins.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const btc = require("./coins/btc.js"); 4 | 5 | module.exports = { 6 | "BTC": btc, 7 | 8 | "coins":["BTC"] 9 | }; -------------------------------------------------------------------------------- /app/credentials.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const os = require('os'); 4 | const path = require('path'); 5 | const url = require('url'); 6 | const fs = require("fs"); 7 | 8 | const debug = require("debug"); 9 | const debugLog = debug("btcexp:config"); 10 | 11 | const btcUri = process.env.BTCEXP_BITCOIND_URI ? url.parse(process.env.BTCEXP_BITCOIND_URI, true) : { query: { } }; 12 | const btcAuth = btcUri.auth ? btcUri.auth.split(':') : []; 13 | 14 | 15 | 16 | 17 | function loadFreshRpcCredentials() { 18 | let username = btcAuth[0] || process.env.BTCEXP_BITCOIND_USER; 19 | let password = btcAuth[1] || process.env.BTCEXP_BITCOIND_PASS; 20 | 21 | let authCookieFilepath = btcUri.query.cookie || process.env.BTCEXP_BITCOIND_COOKIE || path.join(os.homedir(), '.bitcoin', '.cookie'); 22 | 23 | let authType = "usernamePassword"; 24 | 25 | if (!username && !password && fs.existsSync(authCookieFilepath)) { 26 | authType = "cookie"; 27 | } 28 | 29 | if (authType == "cookie") { 30 | debugLog(`Loading RPC cookie file: ${authCookieFilepath}`); 31 | 32 | [ username, password ] = fs.readFileSync(authCookieFilepath).toString().trim().split(':', 2); 33 | 34 | if (!password) { 35 | throw new Error(`Cookie file ${authCookieFilepath} in unexpected format`); 36 | } 37 | } 38 | 39 | return { 40 | host: btcUri.hostname || process.env.BTCEXP_BITCOIND_HOST || "127.0.0.1", 41 | port: btcUri.port || process.env.BTCEXP_BITCOIND_PORT || 8332, 42 | 43 | authType: authType, 44 | 45 | username: username, 46 | password: password, 47 | 48 | authCookieFilepath: authCookieFilepath, 49 | 50 | timeout: parseInt(btcUri.query.timeout || process.env.BTCEXP_BITCOIND_RPC_TIMEOUT || 5000), 51 | }; 52 | } 53 | 54 | module.exports = { 55 | loadFreshRpcCredentials: loadFreshRpcCredentials, 56 | 57 | rpc: loadFreshRpcCredentials(), 58 | 59 | // optional: enter your api access key from ipstack.com below 60 | // to include a map of the estimated locations of your node's 61 | // peers 62 | // format: "ID_FROM_IPSTACK" 63 | ipStackComApiAccessKey: process.env.BTCEXP_IPSTACK_APIKEY, 64 | 65 | // optional: enter your api access key from mapbox.com below 66 | // to enable the tiles for map of the estimated locations of 67 | // your node's peers 68 | // format: "APIKEY_FROM_MAPBOX" 69 | mapBoxComApiAccessKey: process.env.BTCEXP_MAPBOX_APIKEY, 70 | 71 | // optional: GA tracking code 72 | // format: "UA-..." 73 | googleAnalyticsTrackingId: process.env.BTCEXP_GANALYTICS_TRACKING, 74 | 75 | // optional: sentry.io error-tracking url 76 | // format: "SENTRY_IO_URL" 77 | sentryUrl: process.env.BTCEXP_SENTRY_URL, 78 | }; 79 | -------------------------------------------------------------------------------- /app/currencies.js: -------------------------------------------------------------------------------- 1 | global.currencyTypes = { 2 | "btc": { 3 | id: "btc", 4 | type:"native", 5 | name:"BTC", 6 | multiplier:1, 7 | default:true, 8 | decimalPlaces:8 9 | }, 10 | "sat": { 11 | id: "sat", 12 | type:"native", 13 | name:"sat", 14 | multiplier:100000000, 15 | decimalPlaces:0 16 | }, 17 | "usd": { 18 | id: "usd", 19 | type:"exchanged", 20 | name:"USD", 21 | multiplier:"usd", 22 | decimalPlaces:2, 23 | symbol:"$" 24 | }, 25 | "eur": { 26 | id: "eur", 27 | type:"exchanged", 28 | name:"EUR", 29 | multiplier:"eur", 30 | decimalPlaces:2, 31 | symbol:"€" 32 | }, 33 | "gbp": { 34 | id: "gbp", 35 | type:"exchanged", 36 | name:"GBP", 37 | multiplier:"gbp", 38 | decimalPlaces:2, 39 | symbol:"£" 40 | }, 41 | }; 42 | 43 | global.currencySymbols = { 44 | "btc": "₿", 45 | "usd": "$", 46 | "eur": "€", 47 | "gbp": "£" 48 | }; -------------------------------------------------------------------------------- /app/normalizeActions.js: -------------------------------------------------------------------------------- 1 | const buildNormalizingRegexes = (baseUrl) => { 2 | return [ 3 | { regex: new RegExp(`^${baseUrl}$`, "i"), action:"index" }, 4 | { regex: new RegExp(`^${baseUrl}block-height/.*`, "i"), action: "block-height" }, 5 | { regex: new RegExp(`^${baseUrl}block/.*`, "i"), action: "block-hash" }, 6 | { regex: new RegExp(`^${baseUrl}block-analysis/.*`, "i"), action: "block-analysis" }, 7 | { regex: new RegExp(`^${baseUrl}tx/.*`, "i"), action: "transaction" }, 8 | { regex: new RegExp(`^${baseUrl}address/.*`, "i"), action: "address" }, 9 | 10 | { regex: new RegExp(`^${baseUrl}api/blocks-by-height/.*`, "i"), action: "api.blocks-by-height" }, 11 | { regex: new RegExp(`^${baseUrl}api/block-headers-by-height/.*`, "i"), action: "api.block-headers-by-height" }, 12 | { regex: new RegExp(`^${baseUrl}api/block-stats-by-height/.*`, "i"), action: "api.block-stats-by-height" }, 13 | { regex: new RegExp(`^${baseUrl}api/mempool-txs/.*`, "i"), action: "api.mempool-txs" }, 14 | { regex: new RegExp(`^${baseUrl}api/raw-tx-with-inputs/.*`, "i"), action: "api.raw-tx-with-inputs" }, 15 | { regex: new RegExp(`^${baseUrl}api/block-tx-summaries/.*`, "i"), action: "api.block-tx-summaries" }, 16 | { regex: new RegExp(`^${baseUrl}api/utils/.*`, "i"), action: "api.utils-func" }, 17 | 18 | { regex: new RegExp(`^${baseUrl}admin/dashboard`, "i"), action: "admin.dashboard" }, 19 | ]; 20 | } 21 | 22 | module.exports = (baseUrl, action) => { 23 | const normalizingRegexes = buildNormalizingRegexes(baseUrl); 24 | 25 | for (let i = 0; i < normalizingRegexes.length; i++) { 26 | if (normalizingRegexes[i].regex.test(action)) { 27 | return normalizingRegexes[i].action; 28 | } 29 | } 30 | 31 | if (action.startsWith(baseUrl)) { 32 | return action.substring(baseUrl.length); 33 | } 34 | 35 | return action; 36 | }; -------------------------------------------------------------------------------- /app/redisCache.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { createClient } = require("redis"); 4 | 5 | const config = require("./config.js"); 6 | const utils = require("./utils.js"); 7 | 8 | let redisClient = null; 9 | if (config.redisUrl) { 10 | redisClient = createClient({url:config.redisUrl}); 11 | } 12 | 13 | function createCache(keyPrefix, onCacheEvent) { 14 | return { 15 | get: async function(key) { 16 | if (!redisClient.isOpen) { 17 | await redisClient.connect(); 18 | } 19 | 20 | const prefixedKey = `${keyPrefix}-${key}`; 21 | 22 | onCacheEvent("redis", "try", prefixedKey); 23 | 24 | try { 25 | let result = await redisClient.get(prefixedKey); 26 | 27 | if (result == null) { 28 | onCacheEvent("redis", "miss", prefixedKey); 29 | 30 | return null; 31 | 32 | } else { 33 | onCacheEvent("redis", "hit", prefixedKey); 34 | 35 | return JSON.parse(result); 36 | } 37 | } catch (err) { 38 | onCacheEvent("redis", "error", prefixedKey); 39 | 40 | utils.logError("328rhwefghsdgsdss", err, {key:prefixedKey}); 41 | 42 | throw err; 43 | } 44 | }, 45 | set: async function(key, obj, maxAgeMillis) { 46 | if (!redisClient.isOpen) { 47 | await redisClient.connect(); 48 | } 49 | 50 | const prefixedKey = `${keyPrefix}-${key}`; 51 | 52 | await redisClient.set(prefixedKey, JSON.stringify(obj), {"PX": maxAgeMillis}); 53 | } 54 | }; 55 | } 56 | 57 | module.exports = { 58 | active: (redisClient != null), 59 | createCache: createCache 60 | } -------------------------------------------------------------------------------- /app/resourceIntegrityHashes.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | { 3 | "bootstrap.bundle.min.js": "sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL", 4 | "chart.min.js": "sha384-9R67RXFKxJXd/+QmlkaEyOhcfa0gbkwDQ4LUr1RvuTZp9u4VDylkCdExAVOi6zqz", 5 | "chartjs-adapter-moment.min.js": "sha384-Z9r2EsEmivx0l8T8TvYoqqGcpO0cCjKbqVXB8tYUa0hIWKtGVl0TmaF263CjS6XR", 6 | "dataTables.bootstrap4.min.js": "sha384-uiSTMvD1kcI19sAHJDVf68medP9HA2E2PzGis9Efmfsdb8p9+mvbQNgFhzii1MEX", 7 | "decimal.js": "sha384-AZER8B64Ei3MdcUsKj9o83PHYCWb4dY9wJz58HzSDLat+G/QlGUdXhIlNOH56LUe", 8 | "highlight.min.js": "sha384-WRBQ3Nk0J+xE63PvRMOqL5e7wVeo/08dicApRgIsnUQV1zXQTP+VxcsVV8AeatLp", 9 | "jquery.dataTables.min.js": "sha384-rgWRqC0OFPisxlUvl332tiM/qmaNxnlY46eksSZD84t+s2vZlqGeHrncwIRX7CGp", 10 | "jquery.min.js": "sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK", 11 | "moment.min.js": "sha384-CJyhAlbbRZX14Q8KxKBt0na1ad4KBs9PklAiNk2Efxs9sgimbIZm9kYLJQeNMUfM", 12 | "sentry.min.js": "sha384-da/Bo2Ah6Uw3mlhl6VINMblg2SyGbSnULKrukse3P5D9PTJi4np9HoKvR19D7zOL", 13 | "site.js": "sha384-G8o2io5zIdQiiVN+4CAGGXPO4UvV8jGkSMlfddSPbqIUd3x5Rv01pNH6ec8QOATu", 14 | "bootstrap-icons.css": "sha384-rJFhkIguED0Z4GX6r6ReHpTCkwWtiPHZnQtWVP0DQWcKHzeJAlYb1m/xdYkeEk+f", 15 | "dark-v1.min.css": "sha384-NJK2FF+pcgXOxhCACC4A35Noub6HxksU2B0JXRUWaCqpSksqX+6UWUG1wXugc/8W", 16 | "dark.min.css": "sha384-+DbPLnXtdAT+C2GMbf14OD2QDEPgBDF47Vf/EzY2sdOrg8stc+u3yqvKYqnct5Ck", 17 | "dataTables.bootstrap4.min.css": "sha384-EkHEUZ6lErauT712zSr0DZ2uuCmi3DoQj6ecNdHQXpMpFNGAQ48WjfXCE5n20W+R", 18 | "highlight.min.css": "sha384-s4RLYRjGGbVqKOyMGGwfxUTMOO6D7r2eom7hWZQ6BjK2Df4ZyfzLXEkonSm0KLIQ", 19 | "light.min.css": "sha384-53K2dzYpqW7F28D8HeJ+m+FFa7gXjm+N7iNchAn0bqB3cyKHW1cpq2lNyoN+4nmf", 20 | "leaflet.css": "sha384-6wKUKNzA6h/S6gZ1lWQppeGaVXvK1AUAsEznGBghzlEu1fNcxJGYVRiroSHr+OwU", 21 | "leaflet.js": "sha384-RFZC58YeKApoNsIbBxf4z6JJXmh+geBSgkCQXFyh+4tiFSJmJBt+2FbjxW7Ar16M" 22 | }; -------------------------------------------------------------------------------- /app/sso.js: -------------------------------------------------------------------------------- 1 | // 2 | // IMPORTANT MESSAGE!!! 3 | // 4 | // Dear contributor, please take great care when modifying this code. 5 | // It was written defensively with attention to a lot of details in order to prevent security issues. 6 | // As a result of such care it avoided a problem that occurred in RTL which had similar but subtly broken logic 7 | // see https://github.com/Ride-The-Lightning/RTL/issues/610 8 | // 9 | // So before you change anything, please think twice about the consequences. 10 | 11 | "use strict"; 12 | 13 | const crypto = require('crypto'); 14 | const fs = require('fs'); 15 | const utils = require("./utils.js"); 16 | 17 | const authCookieName = "btcexp_auth"; 18 | 19 | function generateToken() { 20 | // Normally we would use 16 => 128 bits of entropy which is sufficiennt 21 | // But since we're going to base64 it and there would be padding (==), 22 | // It's a wasted space, why not use padding for some additional entropy? :) 23 | // The replacing is to make it URL-safe 24 | return crypto.randomBytes(18).toString("base64").replace(/\+/g, '-').replace(/\//g, '_') 25 | } 26 | 27 | function updateToken(tokenFile) { 28 | // This implements atomic update of the token file to avoid corrupted tokens causing trouble 29 | // If first saves the token into a temporary file and then moves it over. The move is atomic. 30 | // The token could also be synced but since the next boot overwrites it anyway, disk corruption 31 | // is not an issue. 32 | var newToken = generateToken(); 33 | var tmpFileName = tokenFile + ".tmp"; 34 | // It is important that we use the generated token, and NOT read back what was written. 35 | // This avoids using predictable token if filesystem gets corrupted (e.g. in case of ENOSPC). 36 | fs.writeFileSync(tmpFileName, newToken); 37 | fs.renameSync(tmpFileName, tokenFile); 38 | 39 | return newToken; 40 | } 41 | 42 | module.exports = (tokenFile, loginRedirect) => { 43 | // Reinitializing the token at start is important due to same reason we don't read it back. 44 | // It also avoids races when another process binds the same port and reads the token in order to later use 45 | // it to attack this app. 46 | var token = updateToken(tokenFile); 47 | var cookies = new Set(); 48 | 49 | return (req, res, next) => { 50 | if (req.cookies && cookies.has(req.cookies[authCookieName])) { 51 | req.authenticated = true; 52 | 53 | return next(); 54 | } 55 | 56 | 57 | let matchingToken = false; 58 | if (req.query.token) { 59 | try { 60 | // We use timingSafeEqual to avoid timing attacks 61 | matchingToken = crypto.timingSafeEqual(Buffer.from(req.query.token, "utf8"), Buffer.from(token, "utf8")); 62 | 63 | } catch (e) { 64 | utils.logError("23rheuweesaa", e); 65 | } 66 | } 67 | 68 | if (matchingToken) { 69 | req.authenticated = true; 70 | token = updateToken(tokenFile); 71 | let cookie = generateToken(); 72 | cookies.add(cookie); 73 | res.cookie(authCookieName, cookie); 74 | 75 | return next(); 76 | } 77 | 78 | if (loginRedirect) { 79 | res.redirect(loginRedirect); 80 | 81 | } else { 82 | res.sendStatus(401); 83 | } 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /app/statTracker.js: -------------------------------------------------------------------------------- 1 | const debug = require("debug"); 2 | const debugLog = debug("statTracker"); 3 | 4 | 5 | let performanceStats = {}; 6 | const trackPerformance = (name, time) => { 7 | if (!performanceStats[name]) { 8 | performanceStats[name] = { 9 | min: time, 10 | max: time, 11 | sum: 0, 12 | count: 0, 13 | firstDate: new Date() 14 | }; 15 | } 16 | 17 | if (time < performanceStats[name].min) { 18 | performanceStats[name].min = time; 19 | } 20 | 21 | if (time > performanceStats[name].max) { 22 | performanceStats[name].max = time; 23 | } 24 | 25 | performanceStats[name].count++; 26 | performanceStats[name].sum += time; 27 | performanceStats[name].avg = performanceStats[name].sum / performanceStats[name].count; 28 | performanceStats[name].lastDate = new Date(); 29 | }; 30 | 31 | let valueStats = {}; 32 | const trackValue = (name, val) => { 33 | if (!valueStats[name]) { 34 | valueStats[name] = { 35 | min: val, 36 | max: val, 37 | sum: 0, 38 | count: 0, 39 | firstDate: new Date() 40 | }; 41 | } 42 | 43 | if (val < valueStats[name].min) { 44 | valueStats[name].min = val; 45 | } 46 | 47 | if (val > valueStats[name].max) { 48 | valueStats[name].max = val; 49 | } 50 | 51 | valueStats[name].count++; 52 | valueStats[name].sum += val; 53 | valueStats[name].avg = valueStats[name].sum / valueStats[name].count; 54 | valueStats[name].lastDate = new Date(); 55 | }; 56 | 57 | let eventStats = {}; 58 | const trackEvent = (name, count=1) => { 59 | if (!eventStats[name]) { 60 | eventStats[name] = 0; 61 | } 62 | 63 | eventStats[name] += count; 64 | }; 65 | 66 | const processAndReset = (perfFunc, valueFunc, eventFunc) => { 67 | for (const [key, value] of Object.entries(performanceStats)) { 68 | perfFunc(key, value); 69 | 70 | //debugLog(key + ": " + JSON.stringify(value)); 71 | } 72 | 73 | for (const [key, value] of Object.entries(valueStats)) { 74 | valueFunc(key, value); 75 | 76 | //debugLog(key + ": " + JSON.stringify(value)); 77 | } 78 | 79 | for (const [key, value] of Object.entries(eventStats)) { 80 | eventFunc(key, {count:value}); 81 | 82 | //debugLog(key + ": " + JSON.stringify(value)); 83 | } 84 | 85 | performanceStats = {}; 86 | valueStats = {}; 87 | eventStats = {}; 88 | }; 89 | 90 | const currentStats = () => { 91 | return { 92 | performance: performanceStats, 93 | event: eventStats, 94 | value: valueStats 95 | }; 96 | }; 97 | 98 | module.exports = { 99 | trackPerformance: trackPerformance, 100 | trackValue: trackValue, 101 | trackEvent: trackEvent, 102 | 103 | currentStats: currentStats, 104 | processAndReset: processAndReset 105 | }; 106 | 107 | 108 | -------------------------------------------------------------------------------- /app/systemMonitor.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const v8 = require("v8"); 3 | const pidusage = require("pidusage"); 4 | const statTracker = require("./statTracker.js"); 5 | const debugLog = require("debug")("systemMonitor"); 6 | 7 | try { 8 | var eventLoopStats = require("event-loop-stats"); 9 | 10 | } catch (err) { 11 | debugLog("Failed loading event-loop-stats, skipping system monitor"); 12 | } 13 | 14 | const systemMonitorInterval = setInterval(() => { 15 | pidusage(process.pid, (err, stat) => { 16 | if (err) { 17 | debugLog(err); 18 | 19 | return; 20 | } 21 | 22 | debugLog("pidusage: " + JSON.stringify(stat)); 23 | 24 | statTracker.trackValue("process.cpu", stat.cpu); 25 | statTracker.trackValue("process.mem_mb", stat.memory / 1024 / 1024); 26 | statTracker.trackValue("process.ctime", stat.ctime); 27 | statTracker.trackValue("process.uptime_s", stat.elapsed / 1000); 28 | 29 | let loadavg = os.loadavg(); 30 | 31 | statTracker.trackValue("os.loadavg.1min", loadavg[0]); 32 | statTracker.trackValue("os.loadavg.5min", loadavg[1]); 33 | statTracker.trackValue("os.loadavg.15min", loadavg[2]); 34 | 35 | let heapStats = v8.getHeapStatistics(); 36 | 37 | statTracker.trackValue("mem.heap.total", heapStats.total_heap_size / 1024 / 1024); 38 | statTracker.trackValue("mem.heap.total-executable", heapStats.total_heap_size_executable / 1024 / 1024); 39 | statTracker.trackValue("mem.heap.total-physical", heapStats.total_physical_size / 1024 / 1024); 40 | statTracker.trackValue("mem.heap.total-available", heapStats.total_available_size / 1024 / 1024); 41 | statTracker.trackValue("mem.heap.used", heapStats.used_heap_size / 1024 / 1024); 42 | statTracker.trackValue("mem.heap.limit", heapStats.heap_size_limit / 1024 / 1024); 43 | statTracker.trackValue("mem.malloced", heapStats.malloced_memory / 1024 / 1024); 44 | statTracker.trackValue("mem.malloced-peak", heapStats.peak_malloced_memory / 1024 / 1024); 45 | 46 | if (eventLoopStats) { 47 | let loopStats = eventLoopStats.sense(); 48 | 49 | statTracker.trackValue("eventloop.min", loopStats.min); 50 | statTracker.trackValue("eventloop.max", loopStats.max); 51 | statTracker.trackValue("eventloop.sum", loopStats.sum); 52 | statTracker.trackValue("eventloop.num", loopStats.num); 53 | } 54 | }); 55 | }, process.env.SYSTEM_MONITOR_INTERVAL || 60 * 60 * 1000); 56 | 57 | systemMonitorInterval.unref(); -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var debug = require("debug"); 4 | var debugLog = debug("btcexp:config"); 5 | 6 | // to debug arg settings, enable the below line: 7 | //debug.enable("btcexp:*"); 8 | 9 | const args = require('meow')(` 10 | Usage 11 | $ btc-rpc-explorer [options] 12 | 13 | Options 14 | -p, --port port to bind http server [default: 3002] 15 | -i, --host host to bind http server [default: 127.0.0.1] 16 | -a, --basic-auth-password <..> protect web interface with a password [default: no password] 17 | -C, --coin crypto-coin to enable [default: BTC] 18 | 19 | -b, --bitcoind-uri connection URI for bitcoind rpc (overrides the options below) 20 | -H, --bitcoind-host hostname for bitcoind rpc [default: 127.0.0.1] 21 | -P, --bitcoind-port port for bitcoind rpc [default: 8332] 22 | -c, --bitcoind-cookie path to bitcoind cookie file [default: ~/.bitcoin/.cookie] 23 | -u, --bitcoind-user username for bitcoind rpc [default: none] 24 | -w, --bitcoind-pass password for bitcoind rpc [default: none] 25 | 26 | --address-api