diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62cd5666d..8a29e9184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain # Latest version available on this commit is 1.71.1 # Commit date is Aug 3, 2023 - uses: dtolnay/rust-toolchain@dc6353516c68da0f06325f42ad880f76a5e77ec9 + uses: dtolnay/rust-toolchain@d8352f6b1d2e870bc5716e7a6d9b65c4cc244a1a with: toolchain: ${{ steps.gettoolchain.outputs.toolchain }} @@ -257,7 +257,7 @@ jobs: spec: | cypress/e2e/mainnet/*.spec.ts cypress/e2e/signet/*.spec.ts - cypress/e2e/testnet/*.spec.ts + cypress/e2e/testnet4/*.spec.ts - module: "liquid" spec: | cypress/e2e/liquid/liquid.spec.ts diff --git a/LICENSE b/LICENSE index b6a09390a..1c368c00a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ The Mempool Open Source Project® -Copyright (c) 2019-2023 Mempool Space K.K. and other shadowy super-coders +Copyright (c) 2019-2024 Mempool Space K.K. and other shadowy super-coders This program is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free @@ -12,10 +12,12 @@ or any other contributor to The Mempool Open Source Project. The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®, Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full -Bitcoin ecosystem™, Mempool Goggles™, the mempool Logo, the mempool Square logo, -the mempool Blocks logo, the mempool Blocks 3 | 2 logo, the mempool.space Vertical -Logo, and the mempool.space Horizontal logo are registered trademarks or trademarks -of Mempool Space K.K in Japan, the United States, and/or other countries. +Bitcoin ecosystem™, Mempool Goggles™, the mempool Logo, the mempool Square Logo, +the mempool block visualization Logo, the mempool Blocks Logo, the mempool +transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo, +the mempool.space Vertical Logo, and the mempool.space Horizontal Logo are +registered trademarks or trademarks of Mempool Space K.K in Japan, +the United States, and/or other countries. See our full Trademark Policy and Guidelines for more details, published on . diff --git a/backend/.eslintrc b/backend/.eslintrc index 1b2889e50..a9b16ef9d 100644 --- a/backend/.eslintrc +++ b/backend/.eslintrc @@ -20,6 +20,7 @@ "@typescript-eslint/no-this-alias": 1, "@typescript-eslint/no-var-requires": 1, "@typescript-eslint/explicit-function-return-type": 1, + "@typescript-eslint/no-unused-vars": 1, "no-console": 1, "no-constant-condition": 1, "no-dupe-else-if": 1, @@ -32,6 +33,8 @@ "prefer-rest-params": 1, "quotes": [1, "single", { "allowTemplateLiterals": true }], "semi": 1, - "eqeqeq": 1 + "curly": [1, "all"], + "eqeqeq": 1, + "no-trailing-spaces": 1 } } diff --git a/backend/README.md b/backend/README.md index cd96a672c..cecc07bc9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -77,7 +77,7 @@ Query OK, 0 rows affected (0.00 sec) #### Build -_Make sure to use Node.js 16.10 and npm 7._ +_Make sure to use Node.js 20.x and npm 9.x or newer_ _The build process requires [Rust](https://www.rust-lang.org/tools/install) to be installed._ @@ -181,7 +181,7 @@ Create a new wallet, if needed: bitcoin-cli -regtest createwallet test ``` -Load wallet (this command may take a while if you have lot of UTXOs): +Load wallet (this command may take a while if you have a lot of UTXOs): ``` bitcoin-cli -regtest loadwallet test ``` @@ -229,13 +229,13 @@ Generate block at regular interval (every 10 seconds in this example): ### Mining pools update -By default, mining pools will be not automatically updated regularly (`config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` is set to `false`). +By default, mining pools will be not automatically updated regularly (`config.MEMPOOL.AUTOMATIC_POOLS_UPDATE` is set to `false`). To manually update your mining pools, you can use the `--update-pools` command line flag when you run the nodejs backend. For example `npm run start --update-pools`. This will trigger the mining pools update and automatically re-index appropriate blocks. -You can enabled the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` to `true` in your `mempool-config.json`. +You can enable the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_POOLS_UPDATE` to `true` in your `mempool-config.json`. -When a `coinbase tag` or `coinbase address` change is detected, all blocks tagged to the `unknown` mining pools (starting from height 130635) will be deleted from the `blocks` table. Additionaly, all blocks which were tagged to the pool which has been updated will also be deleted from the `blocks` table. Of course, those blocks will be automatically reindexed. +When a `coinbase tag` or `coinbase address` change is detected, pool assignments for all relevant blocks (tagged to that pool or the `unknown` mining pool, starting from height 130635) are updated using the new criteria. ### Re-index tables diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index c93774fba..4650c1e64 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -24,7 +24,7 @@ "EXTERNAL_RETRY_INTERVAL": 0, "USER_AGENT": "mempool", "STDOUT_LOG_MIN_PRIORITY": "debug", - "AUTOMATIC_BLOCK_REINDEXING": false, + "AUTOMATIC_POOLS_UPDATE": false, "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", "AUDIT": false, @@ -35,7 +35,8 @@ "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, "ALLOW_UNREACHABLE": true, "PRICE_UPDATES_PER_HOUR": 1, - "MAX_TRACKED_ADDRESSES": 100 + "MAX_TRACKED_ADDRESSES": 100, + "UNIX_SOCKET_PATH": "" }, "CORE_RPC": { "HOST": "127.0.0.1", @@ -58,7 +59,8 @@ "RETRY_UNIX_SOCKET_AFTER": 30000, "REQUEST_TIMEOUT": 10000, "FALLBACK_TIMEOUT": 5000, - "FALLBACK": [] + "FALLBACK": [], + "MAX_BEHIND_TIP": 2 }, "SECOND_CORE_RPC": { "HOST": "127.0.0.1", @@ -138,6 +140,8 @@ "ENABLED": false, "AUDIT": false, "AUDIT_START_HEIGHT": 774000, + "STATISTICS": false, + "STATISTICS_START_TIME": 1481932800, "SERVERS": [ "list", "of", @@ -151,6 +155,7 @@ }, "FIAT_PRICE": { "ENABLED": true, + "PAID": false, "API_KEY": "your-api-key-from-freecurrencyapi.com" } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 8c53c55ad..126660166 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,33 +1,33 @@ { "name": "mempool-backend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-backend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "hasInstallScript": true, "license": "GNU Affero General Public License v3.0", "dependencies": { - "@babel/core": "^7.24.0", + "@babel/core": "^7.25.2", "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", - "axios": "~1.6.1", + "axios": "~1.7.4", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", "express": "~4.19.2", "maxmind": "~4.3.11", - "mysql2": "~3.9.1", - "redis": "^4.6.6", + "mysql2": "~3.11.0", + "redis": "^4.7.0", "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.18.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", - "@babel/core": "^7.24.0", + "@babel/core": "^7.25.2", "@types/compression": "^1.7.2", "@types/crypto-js": "^4.1.1", "@types/express": "^4.17.17", @@ -73,43 +73,43 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", + "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", - "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.0", - "@babel/parser": "^7.24.0", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -131,14 +131,14 @@ "dev": true }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dev": true, "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.25.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -146,28 +146,28 @@ } }, "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", - "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -175,63 +175,29 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -250,88 +216,77 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", - "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", + "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", "dev": true, "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", - "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.0.tgz", + "integrity": "sha512-CzdIU9jdP0dg7HdyB+bHvDJGagUv+qtzZt5rYCWwW6tITNqV9odjp6Qu41gkG0ca5UfdDUWrKkiAnHHdGRnOrA==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -518,33 +473,30 @@ } }, "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", - "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.2.tgz", + "integrity": "sha512-s4/r+a7xTnny2O6FcZzqgT6nE4/GHEdcqj4qAeglbUOh0TeglEfmNJFAd/OLoVtGd6ZhAO8GCVvCNUO5t/VJVQ==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -553,13 +505,13 @@ } }, "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -1475,9 +1427,9 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -1490,13 +1442,13 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@mempool/electrum-client": { @@ -1562,9 +1514,9 @@ } }, "node_modules/@redis/client": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", - "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -1580,33 +1532,33 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@redis/graph": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", - "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/json": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", - "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/search": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.2.tgz", - "integrity": "sha512-/cMfstG/fOh/SsE+4/BQGeuH/JJloeWuH+qJzM8dbxuWvdWibWAOAHHCZTMPhV3xIlH4/cUEIA8OV5QnYpaVoA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/time-series": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", - "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", "peerDependencies": { "@redis/client": "^1.0.0" } @@ -2317,12 +2269,20 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.1.tgz", + "integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", - "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -2587,9 +2547,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", + "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", "dev": true, "funding": [ { @@ -2606,10 +2566,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", + "caniuse-lite": "^1.0.30001640", + "electron-to-chromium": "^1.4.820", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -2707,9 +2667,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001591", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", - "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", + "version": "1.0.30001644", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001644.tgz", + "integrity": "sha512-YGvlOZB4QhZuiis+ETS0VXR+MExbFf4fZYYeMTEE0aTQd/RdIjkTyZjLrbYVKnHzppDvnOhritRVv+i7Go6mHw==", "dev": true, "funding": [ { @@ -3046,9 +3006,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.686", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.686.tgz", - "integrity": "sha512-3avY1B+vUzNxEgkBDpKOP8WarvUAEwpRaiCL0He5OKWEFxzaOFiq4WoZEZe7qh0ReS7DiWoHMnYoQCKxNZNzSg==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz", + "integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==", "dev": true }, "node_modules/emittery": { @@ -3106,9 +3066,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -6197,10 +6157,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mysql2": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", - "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.0.tgz", + "integrity": "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA==", "dependencies": { + "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", @@ -6279,9 +6240,9 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "node_modules/normalize-path": { @@ -6490,9 +6451,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -6748,16 +6709,16 @@ "dev": true }, "node_modules/redis": { - "version": "4.6.6", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.6.tgz", - "integrity": "sha512-aLs2fuBFV/VJ28oLBqYykfnhGGkFxvx0HdCEBYdJ99FFbSEMZ7c1nVKwR6ZRv+7bb7JnC0mmCzaqu8frgOYhpA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", "dependencies": { "@redis/bloom": "1.2.0", - "@redis/client": "1.5.7", - "@redis/graph": "1.1.0", - "@redis/json": "1.0.4", - "@redis/search": "1.1.2", - "@redis/time-series": "1.0.4" + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" } }, "node_modules/require-directory": { @@ -7514,9 +7475,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "funding": [ { @@ -7533,8 +7494,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -7690,9 +7651,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "engines": { "node": ">=10.0.0" }, @@ -7794,37 +7755,37 @@ } }, "@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, "requires": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" } }, "@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", + "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", "dev": true }, "@babel/core": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", - "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "requires": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.0", - "@babel/parser": "^7.24.0", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -7841,88 +7802,63 @@ } }, "@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dev": true, "requires": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.25.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "dependencies": { "@jridgewell/gen-mapping": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", - "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "requires": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" } } } }, "@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dev": true, "requires": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, - "@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true - }, - "@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "requires": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, "@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dev": true, "requires": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" } }, "@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, "requires": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" } }, "@babel/helper-plugin-utils": { @@ -7932,67 +7868,59 @@ "dev": true }, "@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dev": true, "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" } }, "@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true }, "@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true }, "@babel/helpers": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", - "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", + "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", "dev": true, "requires": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.0" } }, "@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" } }, "@babel/parser": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", - "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.0.tgz", + "integrity": "sha512-CzdIU9jdP0dg7HdyB+bHvDJGagUv+qtzZt5rYCWwW6tITNqV9odjp6Qu41gkG0ca5UfdDUWrKkiAnHHdGRnOrA==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -8122,42 +8050,39 @@ } }, "@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "requires": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" } }, "@babel/traverse": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", - "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.2.tgz", + "integrity": "sha512-s4/r+a7xTnny2O6FcZzqgT6nE4/GHEdcqj4qAeglbUOh0TeglEfmNJFAd/OLoVtGd6ZhAO8GCVvCNUO5t/VJVQ==", "dev": true, "requires": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", "debug": "^4.3.1", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" } }, @@ -8847,9 +8772,9 @@ "dev": true }, "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, "@jridgewell/sourcemap-codec": { @@ -8859,13 +8784,13 @@ "dev": true }, "@jridgewell/trace-mapping": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "@mempool/electrum-client": { @@ -8911,9 +8836,9 @@ "requires": {} }, "@redis/client": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", - "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", "requires": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -8928,27 +8853,27 @@ } }, "@redis/graph": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", - "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", "requires": {} }, "@redis/json": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", - "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", "requires": {} }, "@redis/search": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.2.tgz", - "integrity": "sha512-/cMfstG/fOh/SsE+4/BQGeuH/JJloeWuH+qJzM8dbxuWvdWibWAOAHHCZTMPhV3xIlH4/cUEIA8OV5QnYpaVoA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", "requires": {} }, "@redis/time-series": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", - "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", "requires": {} }, "@sinclair/typebox": { @@ -9508,12 +9433,17 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "aws-ssl-profiles": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.1.tgz", + "integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ==" + }, "axios": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", - "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "requires": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -9727,15 +9657,15 @@ } }, "browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", + "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", + "caniuse-lite": "^1.0.30001640", + "electron-to-chromium": "^1.4.820", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.1.0" } }, "bs-logger": { @@ -9809,9 +9739,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001591", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", - "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", + "version": "1.0.30001644", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001644.tgz", + "integrity": "sha512-YGvlOZB4QhZuiis+ETS0VXR+MExbFf4fZYYeMTEE0aTQd/RdIjkTyZjLrbYVKnHzppDvnOhritRVv+i7Go6mHw==", "dev": true }, "chalk": { @@ -10049,9 +9979,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron-to-chromium": { - "version": "1.4.686", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.686.tgz", - "integrity": "sha512-3avY1B+vUzNxEgkBDpKOP8WarvUAEwpRaiCL0He5OKWEFxzaOFiq4WoZEZe7qh0ReS7DiWoHMnYoQCKxNZNzSg==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz", + "integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==", "dev": true }, "emittery": { @@ -10094,9 +10024,9 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true }, "escape-html": { @@ -12382,10 +12312,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "mysql2": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", - "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.0.tgz", + "integrity": "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA==", "requires": { + "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", @@ -12450,9 +12381,9 @@ "dev": true }, "node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "normalize-path": { @@ -12601,9 +12532,9 @@ "dev": true }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "picomatch": { @@ -12770,16 +12701,16 @@ "dev": true }, "redis": { - "version": "4.6.6", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.6.tgz", - "integrity": "sha512-aLs2fuBFV/VJ28oLBqYykfnhGGkFxvx0HdCEBYdJ99FFbSEMZ7c1nVKwR6ZRv+7bb7JnC0mmCzaqu8frgOYhpA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", "requires": { "@redis/bloom": "1.2.0", - "@redis/client": "1.5.7", - "@redis/graph": "1.1.0", - "@redis/json": "1.0.4", - "@redis/search": "1.1.2", - "@redis/time-series": "1.0.4" + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" } }, "require-directory": { @@ -13299,13 +13230,13 @@ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, "update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" } }, "uri-js": { @@ -13424,9 +13355,9 @@ } }, "ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "requires": {} }, "y18n": { diff --git a/backend/package.json b/backend/package.json index 2bf1ae92b..51abf2f7b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-backend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", @@ -39,24 +39,24 @@ "prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"" }, "dependencies": { - "@babel/core": "^7.24.0", + "@babel/core": "^7.25.2", "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", - "axios": "~1.6.1", + "axios": "~1.7.4", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", "express": "~4.19.2", "maxmind": "~4.3.11", - "mysql2": "~3.9.1", + "mysql2": "~3.11.0", "rust-gbt": "file:./rust-gbt", - "redis": "^4.6.6", + "redis": "^4.7.0", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.18.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", - "@babel/core": "^7.24.0", + "@babel/core": "^7.25.2", "@types/compression": "^1.7.2", "@types/crypto-js": "^4.1.1", "@types/express": "^4.17.17", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 1c973c45b..3796b7f22 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -7,9 +7,10 @@ "BLOCKS_SUMMARIES_INDEXING": true, "GOGGLES_INDEXING": false, "HTTP_PORT": 1, + "UNIX_SOCKET_PATH": "/mempool/socket/mempool-bitcoin-mainnet", "SPAWN_CLUSTER_PROCS": 2, "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", - "AUTOMATIC_BLOCK_REINDEXING": false, + "AUTOMATIC_POOLS_UPDATE": false, "POLL_RATE_MS": 3, "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", "CACHE_ENABLED": true, @@ -59,7 +60,8 @@ "RETRY_UNIX_SOCKET_AFTER": 888, "REQUEST_TIMEOUT": 10000, "FALLBACK_TIMEOUT": 5000, - "FALLBACK": [] + "FALLBACK": [], + "MAX_BEHIND_TIP": 2 }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", @@ -130,6 +132,8 @@ "ENABLED": false, "AUDIT": false, "AUDIT_START_HEIGHT": 774000, + "STATISTICS": false, + "STATISTICS_START_TIME": 1481932800, "SERVERS": [] }, "MEMPOOL_SERVICES": { @@ -143,6 +147,7 @@ }, "FIAT_PRICE": { "ENABLED": true, + "PAID": false, "API_KEY": "__MEMPOOL_CURRENCY_API_KEY__" } } diff --git a/backend/src/__tests__/api/common.ts b/backend/src/__tests__/api/common.ts index 10d6df868..74a7db88f 100644 --- a/backend/src/__tests__/api/common.ts +++ b/backend/src/__tests__/api/common.ts @@ -4,21 +4,37 @@ import { MempoolTransactionExtended } from '../../mempool.interfaces'; const randomTransactions = require('./test-data/transactions-random.json'); const replacedTransactions = require('./test-data/transactions-replaced.json'); const rbfTransactions = require('./test-data/transactions-rbfs.json'); +const nonStandardTransactions = require('./test-data/non-standard-txs.json'); -describe('Mempool Utils', () => { - test('should detect RBF transactions with fast method', () => { +describe('Common', () => { + describe('RBF', () => { const newTransactions = rbfTransactions.concat(randomTransactions); - const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions); - expect(Object.values(result).length).toEqual(2); - expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); - expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); + test('should detect RBF transactions with fast method', () => { + const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions); + expect(Object.values(result).length).toEqual(2); + expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); + expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); + }); + + test('should detect RBF transactions with scalable method', () => { + const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions, true); + expect(Object.values(result).length).toEqual(2); + expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); + expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); + }); }); - test.only('should detect RBF transactions with scalable method', () => { - const newTransactions = rbfTransactions.concat(randomTransactions); - const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions, true); - expect(Object.values(result).length).toEqual(2); - expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); - expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); + describe('Mempool Goggles', () => { + test('should detect nonstandard transactions', () => { + nonStandardTransactions.forEach((tx) => { + expect(Common.isNonStandard(tx)).toEqual(true); + }); + }); + + test('should not misclassify as nonstandard transactions', () => { + randomTransactions.forEach((tx) => { + expect(Common.isNonStandard(tx)).toEqual(false); + }); + }); }); }); diff --git a/backend/src/__tests__/api/test-data/non-standard-txs.json b/backend/src/__tests__/api/test-data/non-standard-txs.json new file mode 100644 index 000000000..286fe2945 --- /dev/null +++ b/backend/src/__tests__/api/test-data/non-standard-txs.json @@ -0,0 +1,52 @@ +[ + { + "txid": "50136231cb7eeeffb17fc41d1cca213426abe5bf3760e3d6421cad0c0edad367", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "c7f86fb7b830124057475b282809f3474ef3565daa3de0b599980fb9e84ab019", + "vout": 4217, + "prevout": { + "scriptpubkey": "001466197b5eadd8067ec194a457e1044b6d1fbdd3b3", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 66197b5eadd8067ec194a457e1044b6d1fbdd3b3", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qvcvhkh4dmqr8asv553t7zpztd50mm5ang4na33", + "value": 106 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3043021f2af6060a142c6cfd7428adad6a50745d2424813d7ced5c0bbcca85e70de1be022021440ca1c8c3ed49ecd1b64dca6911adcd430c5d3dd60d77ffe0072953999f5b01", + "02ead5c34e3d2c506574b562f857576e11380b6ba15d9f0ad7b7303fdaa9c1513d" + ], + "is_coinbase": false, + "sequence": 4294967295 + } + ], + "vout": [ + { + "scriptpubkey": "6a023a29", + "scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_2 3a29", + "scriptpubkey_type": "op_return", + "value": 0 + }, + { + "scriptpubkey": "6a036d7648", + "scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_3 6d7648", + "scriptpubkey_type": "op_return", + "value": 0 + } + ], + "size": 186, + "weight": 420, + "sigops": 1, + "fee": 106, + "status": { + "confirmed": true, + "block_height": 836361, + "block_hash": "0000000000000000000341cc26cda4af82cd25f7063c448772228cbf2836915b", + "block_time": 1711448028 + } + } +] \ No newline at end of file diff --git a/backend/src/__tests__/api/test-data/transactions-random.json b/backend/src/__tests__/api/test-data/transactions-random.json index 6bf4a0edd..4bc0b731c 100644 --- a/backend/src/__tests__/api/test-data/transactions-random.json +++ b/backend/src/__tests__/api/test-data/transactions-random.json @@ -273,5 +273,328 @@ }, "bestDescendant": null, "cpfpChecked": true + }, + { + "txid": "20b984492b5264162a4c92c9a34bc7fa08b67d669de7b4c5982ad3cb28aaecf6", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "3adda6afd547193793c248e667c2b7dbf26d705003de65e3a25e5be698286aef", + "vout": 2, + "prevout": { + "scriptpubkey": "0014989cf12774fc705609610c7b9419f2d1c4807644", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 989cf12774fc705609610c7b9419f2d1c4807644", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qnzw0zfm5l3c9vztpp3aegx0j68zgqajyffr2r6", + "value": 27619 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "304402205d7f1e0d928982645c2bcc4c730c4545c382d6520c2a14eebc71594702cd06b302200511d452ce51c79017536f50acb115eefe7c04506ad12b9307d2b5d56b999beb01", + "03716cb4f0430fe69c596a12c6680c55803150645989b406772838d548cde7cca5" + ], + "is_coinbase": false, + "sequence": 4294967295 + } + ], + "vout": [ + { + "scriptpubkey": "6a5d0614c0a2331441", + "scriptpubkey_asm": "OP_RETURN OP_PUSHNUM_13 OP_PUSHBYTES_6 14c0a2331441", + "scriptpubkey_type": "op_return", + "value": 0 + }, + { + "scriptpubkey": "5114d71c6c3ea7ba7e6ee477a0bfd82c20c78997882c", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_20 d71c6c3ea7ba7e6ee477a0bfd82c20c78997882c", + "scriptpubkey_type": "unknown", + "scriptpubkey_address": "bc1p6uwxc048hflxaerh5zlastpqc7ye0zpvq7gq2a", + "value": 546 + }, + { + "scriptpubkey": "0014989cf12774fc705609610c7b9419f2d1c4807644", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 989cf12774fc705609610c7b9419f2d1c4807644", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qnzw0zfm5l3c9vztpp3aegx0j68zgqajyffr2r6", + "value": 23073 + } + ], + "size": 240, + "weight": 633, + "sigops": 1, + "fee": 4000, + "status": { + "confirmed": true, + "block_height": 848136, + "block_hash": "00000000000000000002c69c7a3010fcd596c0c7451c23e7cd1f5e19ebf8ee6d", + "block_time": 1718517071 + } + }, + { + "txid": "b10c0000004da5a9d1d9b4ae32e09f0b3e62d21a5cce5428d4ad714fb444eb5d", + "version": 1, + "locktime": 1231006505, + "vin": [ + { + "txid": "d46a24962c1d7bd6e87d80570c6a53413eaf30d7fde7f52347f13645ae53969b", + "vout": 0, + "prevout": { + "scriptpubkey": "41049434a2dd7c5b82df88f578f8d7fd14e8d36513aaa9c003eb5bd6cb56065e44b7e0227139e8a8e68e7de0a4ed32b8c90edc9673b8a7ea541b52f2a22196f7b8cfac", + "scriptpubkey_asm": "OP_PUSHBYTES_65 049434a2dd7c5b82df88f578f8d7fd14e8d36513aaa9c003eb5bd6cb56065e44b7e0227139e8a8e68e7de0a4ed32b8c90edc9673b8a7ea541b52f2a22196f7b8cf OP_CHECKSIG", + "scriptpubkey_type": "p2pk", + "value": 6102 + }, + "scriptsig": "473044022004f027ae0b19bb7a7aa8fcdf135f1da769d087342020359ef4099a9f0f0ba4ec02206a83a9b78df3fed89a3b6052e69963e1fb08d8f6d17d945e43b51b5214aa41e601", + "scriptsig_asm": "OP_PUSHBYTES_71 3044022004f027ae0b19bb7a7aa8fcdf135f1da769d087342020359ef4099a9f0f0ba4ec02206a83a9b78df3fed89a3b6052e69963e1fb08d8f6d17d945e43b51b5214aa41e601", + "is_coinbase": false, + "sequence": 20090103 + }, + { + "txid": "cb9b47ac04023b29fb633a8ef04af351ac9fd74c57c9a2163f683516274767e3", + "vout": 0, + "prevout": { + "scriptpubkey": "76a914bbb1f7d0f7e15ac088af9bafe25aaac1a59832d088ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 bbb1f7d0f7e15ac088af9bafe25aaac1a59832d0 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1J7SZJry7CX4zWdH3P8E8UJjZrhcLEjJ39", + "value": 1913 + }, + "scriptsig": "46304302204dc2939be89ab6626457fff40aec2cc4e6213e64bcb4d2c43bf6b49358ff638c021f33d2f8fdf6d54a2c82bb7cddc62becc2cbbaca6fd7f3ec927ea975f29ad8510221028b98707adfd6f468d56c1a6067a6f0c7fef43afbacad45384017f8be93a18d40", + "scriptsig_asm": "OP_PUSHBYTES_70 304302204dc2939be89ab6626457fff40aec2cc4e6213e64bcb4d2c43bf6b49358ff638c021f33d2f8fdf6d54a2c82bb7cddc62becc2cbbaca6fd7f3ec927ea975f29ad85102 OP_PUSHBYTES_33 028b98707adfd6f468d56c1a6067a6f0c7fef43afbacad45384017f8be93a18d40", + "is_coinbase": false, + "sequence": 20081031 + }, + { + "txid": "cb9b47ac04023b29fb633a8ef04af351ac9fd74c57c9a2163f683516274767e3", + "vout": 1, + "prevout": { + "scriptpubkey": "52210304e708d258a632ffb128a62ecf5eebd1904e505497d031619513afc8bca7858f2102b9dc03f1133e7cbc7eb311631acc2dbda908fb0f0fae095da2f4dd427f51308a4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f53ae", + "scriptpubkey_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 0304e708d258a632ffb128a62ecf5eebd1904e505497d031619513afc8bca7858f OP_PUSHBYTES_33 02b9dc03f1133e7cbc7eb311631acc2dbda908fb0f0fae095da2f4dd427f51308a OP_PUSHBYTES_65 04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f OP_PUSHNUM_3 OP_CHECKMULTISIG", + "scriptpubkey_type": "multisig", + "value": 1971 + }, + "scriptsig": "00453042021e4f6ff73d7b304a5cbf3bb7738abb5f81a4af6335962134ce27a1cc45fec702201b95e3acb7db93257b20651cdcb79af66bf0bb86a8ae5b4e0a5df4e3f86787e2033b303802153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021f34793e2878497561e7616291ebdda3024b681cdacc8b863b5b0804cd30c2a481", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_69 3042021e4f6ff73d7b304a5cbf3bb7738abb5f81a4af6335962134ce27a1cc45fec702201b95e3acb7db93257b20651cdcb79af66bf0bb86a8ae5b4e0a5df4e3f86787e203 OP_PUSHBYTES_59 303802153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021f34793e2878497561e7616291ebdda3024b681cdacc8b863b5b0804cd30c2a481", + "is_coinbase": false, + "sequence": 19750504 + }, + { + "txid": "45e1cb33599acb071810ccc801b71bd7610865f5b899492946ab1bfbcb61cad6", + "vout": 0, + "prevout": { + "scriptpubkey": "a91419f0b86f61606c6eb51b217698ca7e8bff1e398b87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 19f0b86f61606c6eb51b217698ca7e8bff1e398b OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "344BBtYkhaCXgA7oYSXASUfh4bFieiponG", + "value": 2140 + }, + "scriptsig": "00443041021d1313459a48bd1d0628eec635495f793e970729684394f9b814d2b24012022050be6d9918444e283da0136884f8311ec465d0fed2f8d24b75a8485ebdc13aea013a303702153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021e78644ba72eab69fefb5fe50700671bfb91dda699f72ffbb325edc6a3c4ef8239303602153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021d2c2db104e70720c39af43b6ba3edd930c26e0818aa59ff9c886281d8ba834ced532103e0a220d36f6f7ed5f3f58c279d055707c454135baf18fd00d798fec3cb52dfbc2103cf689db9313b9f7fc0b984dd9cac750be76041b392919b06f6bf94813da34cd421027f8af2eb6e904deddaa60d5af393d430575eb35e4dfd942a8a5882734b078906410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a34104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84c55ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_68 3041021d1313459a48bd1d0628eec635495f793e970729684394f9b814d2b24012022050be6d9918444e283da0136884f8311ec465d0fed2f8d24b75a8485ebdc13aea01 OP_PUSHBYTES_58 303702153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021e78644ba72eab69fefb5fe50700671bfb91dda699f72ffbb325edc6a3c4ef82 OP_PUSHBYTES_57 303602153b78ce563f89a0ed9414f5aa28ad0d96d6795f9c63021d2c2db104e70720c39af43b6ba3edd930c26e0818aa59ff9c886281d8ba83 OP_PUSHDATA1 532103e0a220d36f6f7ed5f3f58c279d055707c454135baf18fd00d798fec3cb52dfbc2103cf689db9313b9f7fc0b984dd9cac750be76041b392919b06f6bf94813da34cd421027f8af2eb6e904deddaa60d5af393d430575eb35e4dfd942a8a5882734b078906410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a34104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84c55ae", + "is_coinbase": false, + "sequence": 16, + "inner_redeemscript_asm": "OP_PUSHNUM_3 OP_PUSHBYTES_33 03e0a220d36f6f7ed5f3f58c279d055707c454135baf18fd00d798fec3cb52dfbc OP_PUSHBYTES_33 03cf689db9313b9f7fc0b984dd9cac750be76041b392919b06f6bf94813da34cd4 OP_PUSHBYTES_33 027f8af2eb6e904deddaa60d5af393d430575eb35e4dfd942a8a5882734b078906 OP_PUSHBYTES_65 0411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3 OP_PUSHBYTES_65 04ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84c OP_PUSHNUM_5 OP_CHECKMULTISIG" + }, + { + "txid": "cb9b47ac04023b29fb633a8ef04af351ac9fd74c57c9a2163f683516274767e3", + "vout": 2, + "prevout": { + "scriptpubkey": "a9143b13a1f71c20c799d86bb624b3898c826d6c82da87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 3b13a1f71c20c799d86bb624b3898c826d6c82da OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "375PJxsKRtAq4WoS6u82jvgZW94R8Wx3iH", + "value": 5139 + }, + "scriptsig": "1600149b27f072e4b972927c445d1946162a550b0914d8", + "scriptsig_asm": "OP_PUSHBYTES_22 00149b27f072e4b972927c445d1946162a550b0914d8", + "witness": [ + "3040021c23902a01d4c5cff2c33c8bdb778a5aadea78a9a0d6d4db60aaa0fba1022069237d9dbf2db8cff9c260ba71250493682d01a746f4a45c5c7ea386e56d2bc902", + "0240187acd3e2fd3d8e1acffefa85907b6550730c24f78dfd3301c829fc4daf3cc" + ], + "is_coinbase": false, + "sequence": 141, + "inner_redeemscript_asm": "OP_0 OP_PUSHBYTES_20 9b27f072e4b972927c445d1946162a550b0914d8" + }, + { + "txid": "cb9b47ac04023b29fb633a8ef04af351ac9fd74c57c9a2163f683516274767e3", + "vout": 3, + "prevout": { + "scriptpubkey": "a914a3c0698f2300c7b2e8107d4c9c988e642110039087", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 a3c0698f2300c7b2e8107d4c9c988e6421100390 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3GcrZrbUuvE4UtUdSbKTXcRnTqmfMdyMAC", + "value": 3220 + }, + "scriptsig": "220020a18160de7291554f349c7d5cbee4ab97fb542e94cf302ce8d7e9747e4188ca75", + "scriptsig_asm": "OP_PUSHBYTES_34 0020a18160de7291554f349c7d5cbee4ab97fb542e94cf302ce8d7e9747e4188ca75", + "witness": [ + "303f021c65aee6696e80be6e14545cfd64b44f17b0514c150eefdb090c0f0bd9021f3fef4aa95c252a225622aba99e4d5af5a6fe40d177acd593e64cf2f8557ccc03", + "03b55c6f0749e0f3e2caeca05f68e3699f1b3c62a550730f704985a6a9aae437a1", + "76a914db865fd920959506111079995f1e4017b489bfe38763ac6721024d560f7f5d28aae5e1a8aa2b7ba615d7fc48e4ea27e5d27336e6a8f5fa0f5c8c7c820120876475527c2103443e8834fa7d79d7b5e95e0e9d0847f6b03ac3ea977979858b4104947fca87ca52ae67a91446c3747322b220fdb925c9802f0e949c1feab99988ac6868" + ], + "is_coinbase": false, + "sequence": 3735928559, + "inner_redeemscript_asm": "OP_0 OP_PUSHBYTES_32 a18160de7291554f349c7d5cbee4ab97fb542e94cf302ce8d7e9747e4188ca75", + "inner_witnessscript_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 db865fd920959506111079995f1e4017b489bfe3 OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 024d560f7f5d28aae5e1a8aa2b7ba615d7fc48e4ea27e5d27336e6a8f5fa0f5c8c OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 03443e8834fa7d79d7b5e95e0e9d0847f6b03ac3ea977979858b4104947fca87ca OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 46c3747322b220fdb925c9802f0e949c1feab999 OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF OP_ENDIF" + }, + { + "txid": "cb9b47ac04023b29fb633a8ef04af351ac9fd74c57c9a2163f683516274767e3", + "vout": 4, + "prevout": { + "scriptpubkey": "0014c0ca6e754e65d3ba59112d7abc33e500c00ecfa7", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 c0ca6e754e65d3ba59112d7abc33e500c00ecfa7", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qcr9xua2wvhfm5kg394atcvl9qrqqana8rrmy8h", + "value": 17144 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "303e021c11f60486afd0f5d6573603fb2076ef2f676455b92ada257d2f25558a021e317719c946f951d49bf4df4285a618629cd9e554fcbf787c319a0c4dd22601", + "032467f24cc31664f0cf34ff8d5cbb590888ddc1dcfec724a32ae3dd5338b8508e" + ], + "is_coinbase": false, + "sequence": 21000000 + }, + { + "txid": "637db3928a8fb1b22b81f92dc738ee7637e5b172d650363d0b327429578bd001", + "vout": 0, + "prevout": { + "scriptpubkey": "0020a9530a167fcada672c142ee636dcd171796e69ef8e37aa1f77f35c58edd7a357", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 a9530a167fcada672c142ee636dcd171796e69ef8e37aa1f77f35c58edd7a357", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1q49fs59nletdxwtq59mnrdhx3w9uku6003cm658mh7dw93mwh5dts2w2kht", + "value": 8149 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "303d021c32f9454db85cb1a4ca63a9883d4347c5e13f3654e884ae44e9efa3c8021d62f07fe452c06b084bc3e09afd3aac4039136549a465533bc1ca66967902", + "01", + "632102fd6db4de50399b2aa086edb23f8e140bbc823d6651e024a0eb871288068789cd67012ab27521034134a2bb35c3f83dab2489d96160741888b8b5589bb694dea6e7bc24486e9c6f68ac" + ], + "is_coinbase": false, + "sequence": 4190024921, + "inner_witnessscript_asm": "OP_IF OP_PUSHBYTES_33 02fd6db4de50399b2aa086edb23f8e140bbc823d6651e024a0eb871288068789cd OP_ELSE OP_PUSHBYTES_1 2a OP_CSV OP_DROP OP_PUSHBYTES_33 034134a2bb35c3f83dab2489d96160741888b8b5589bb694dea6e7bc24486e9c6f OP_ENDIF OP_CHECKSIG" + }, + { + "txid": "0020db02df125062ebae5bacd189ebff22577b2817c1872be79a0d3ba3982c41", + "vout": 0, + "prevout": { + "scriptpubkey": "512071212ded0ff4c9b1b0c505d8012772e2dbe98a3cae7168377b950fb6b866a849", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 71212ded0ff4c9b1b0c505d8012772e2dbe98a3cae7168377b950fb6b866a849", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pwysjmmg07nymrvx9qhvqzfmjutd7nz3u4ecksdmmj58mdwrx4pysq6m68g", + "value": 9001 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "d822f203827852998cad370232e8c57294540a5da51107fa26cf466bdd2b8b0b3d161999cc80aed8de7386a2bd5d5313aea159a231cc26fa53aaa702b7fa21ed" + ], + "is_coinbase": false, + "sequence": 341 + }, + { + "txid": "795741ecf9c431b14b1c8d2dd017d3978fd4f6452e91edf416f31ef9971206b4", + "vout": 0, + "prevout": { + "scriptpubkey": "512089ac120a490eee88db5588112f95f88093284c814f07c3ad943a7faefba2271a", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 89ac120a490eee88db5588112f95f88093284c814f07c3ad943a7faefba2271a", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p3xkpyzjfpmhg3k643qgjl90cszfjsnypfuru8tv58fl6a7azyudqkcu66k", + "value": 19953 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "fe6eb715dceffefc067fdc787d250a9a9116682d216f6356ea38fc1f112bd74995faa90315e81981d2c2260b7eaca3c41a16b280362980f0d8faf4c05ebb82c5", + "e34ad0ad33885a473831f8ba8d9339123cb19d0e642e156d8e0d6e2ab2691aedb30e55a35637a806927225e1aa72223d41e59f92c6579b819e7d331a7ada9d2e01", + "2a4861fb4cb951c791bf6c93859ef65abccd90034f91b9b77abb918e13b6fce75d5fa3e2d2f6eeeae105315178c2cb9db2ef238fe89b282f691c06db43bc71ca02", + "fc97bb2be673c3bf388aaf58178ef14d354caf83c92aca8ef1831d619b8511e928f4f5fdea3962067b11e7cecfe094cd0f66a4ea9af9ec836d70d18f2b37df0281", + "a5781a0adaa80ab7f7f164172dd1a1cb127e523daa0d6949aba074a15c589f12dfb8183182afec9230cb7947b7422a4abc1bb78173550d66274ea19f6c9dd92c82", + "", + "", + "205f4237bd7dae576b34abc8a9c6fa4f0e4787c04234ca963e9e96c8f9b67b56d1ac205f4237bd7f93c69403a30c6b641f27ccf5201090152fcf1596474221307831c3ba205ac8ff25ce63564963d1148b84627f614af1f3c77d7caa23adc61264fa5e4996ba20b210c83e6f5b3f866837112d023d9ae8da2a6412168d54968ab87860ab970690ba20d3ee3b7a8b8149122b3c886330b3241538ba4b935c4040f4a73ddab917241bc5ba20cdfabb9d0e5c8f09a83f19e36e100d8f5e882f1b60aa60dacd9e6d072c117bc0ba20aab038c238e95fb54cdd0a6705dc1b1f8d135a9e9b20ab9c7ff96eef0e9bf545ba559c", + "c0b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f5534a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33bf4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e166f7cf9580f1c2dfb3c4d5d043cdbb128c640e3f20161245aa7372e9666168516a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48dd5d27987d2a3dfc724e359870c6644b40e497bdc0589a033220fe15429d88599e3bf3d07d4b0375638d5f1db5255fe07ba2c4cb067cd81b84ee974b6585fb46829a3efd3ef04f9153d47a990bd7b048a4b2d213daaa5fb8ed670fb85f13bdbcf54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713d29c9c0e8e4d2a9790922af73f0b8d51f0bd4bb19940d9cf910ead8fbe85bc9bbb41a757f405890fb0f5856228e23b715702d714d59bf2b1feb70d8b2b4e3e089fdbcf0ef9d8d00f66e47917f67cc5d78aec1ac786e2abb8d2facb4e4790aad6cc455ae816e6cdafdb58d54e35d4f46d860047458eacf1c7405dc634631c570d8d31992805518fd62daa3bdd2a5c4fd2cd3054c9b3dca1d78055e9528cff6adc8f907925d2ebe48765103e6845c06f1f2bb77c6adc1cc002865865eb5cfd5c1cb10c007c60e14f9d087e0291d4d0c7869697c6681d979c6639dbd960792b4d4133e794d097969002ee05d336686fc03c9e15a597c1b9827669460fac9879903637777defed8717c581b4c0509329550e344bdc14ac38f71fc050096887e535c8fd456524104a6674693c29946543f8a0befccce5a352bda55ec8559fc630f5f37393096d97bfee8660f4100ffd61874d62f9a65de9fb6acf740c4c386990ef7373be398c4bdc43709db7398106609eea2a7841aaf3a4fa2000dc18184faa2a7eb5a2af5845a8d3796308ff9840e567b14cf6bb158ff26c999e6f9a1f5448f9aa" + ], + "is_coinbase": false, + "sequence": 342, + "inner_witnessscript_asm": "OP_PUSHBYTES_32 5f4237bd7dae576b34abc8a9c6fa4f0e4787c04234ca963e9e96c8f9b67b56d1 OP_CHECKSIG OP_PUSHBYTES_32 5f4237bd7f93c69403a30c6b641f27ccf5201090152fcf1596474221307831c3 OP_CHECKSIGADD OP_PUSHBYTES_32 5ac8ff25ce63564963d1148b84627f614af1f3c77d7caa23adc61264fa5e4996 OP_CHECKSIGADD OP_PUSHBYTES_32 b210c83e6f5b3f866837112d023d9ae8da2a6412168d54968ab87860ab970690 OP_CHECKSIGADD OP_PUSHBYTES_32 d3ee3b7a8b8149122b3c886330b3241538ba4b935c4040f4a73ddab917241bc5 OP_CHECKSIGADD OP_PUSHBYTES_32 cdfabb9d0e5c8f09a83f19e36e100d8f5e882f1b60aa60dacd9e6d072c117bc0 OP_CHECKSIGADD OP_PUSHBYTES_32 aab038c238e95fb54cdd0a6705dc1b1f8d135a9e9b20ab9c7ff96eef0e9bf545 OP_CHECKSIGADD OP_PUSHNUM_5 OP_NUMEQUAL" + } + ], + "vout": [ + { + "scriptpubkey": "210261542eb020b36c1da48e2e607b90a8c1f2ccdbd06eaf5fb4bb0d7cc34293d32aac", + "scriptpubkey_asm": "OP_PUSHBYTES_33 0261542eb020b36c1da48e2e607b90a8c1f2ccdbd06eaf5fb4bb0d7cc34293d32a OP_CHECKSIG", + "scriptpubkey_type": "p2pk", + "value": 576 + }, + { + "scriptpubkey": "76a9140240539af6c68431e4ce9cc5ef464f12c1741b3c88ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 0240539af6c68431e4ce9cc5ef464f12c1741b3c OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1CuQsdrcgcmPvugo3NqEwh1kDcpeEnuFC", + "value": 546 + }, + { + "scriptpubkey": "5121028b45a50f795be0413680036665d17a3eca099648ea80637bc3a70a7d2b52ae2851ae", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_33 028b45a50f795be0413680036665d17a3eca099648ea80637bc3a70a7d2b52ae28 OP_PUSHNUM_1 OP_CHECKMULTISIG", + "scriptpubkey_type": "multisig", + "value": 582 + }, + { + "scriptpubkey": "a91449ed2c96e33b6134408af8484508bcc3248c8dbd87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 49ed2c96e33b6134408af8484508bcc3248c8dbd OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "38RuNhSiZiftB6WVnStu5aUz6jXtCDXQZk", + "value": 540 + }, + { + "scriptpubkey": "0014c8e51cf6891c0a2101aecea8cd5ce9bbbfaf7bba", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 c8e51cf6891c0a2101aecea8cd5ce9bbbfaf7bba", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qerj3ea5frs9zzqdwe65v6h8fhwl677a6s0hxhf", + "value": 294 + }, + { + "scriptpubkey": "0020c485bbb80c4be276e77eac3a983a391cc8b1a1b5f160995a36c3dff18296385a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 c485bbb80c4be276e77eac3a983a391cc8b1a1b5f160995a36c3dff18296385a", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1qcjzmhwqvf038dem74safsw3ernytrgd479sfjk3kc00lrq5k8pdqczl83q", + "value": 330 + }, + { + "scriptpubkey": "5120a7a42b268957a06c9de4d7260f1df392ce4d6e7b743f5adc27415ce2afceb3b9", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 a7a42b268957a06c9de4d7260f1df392ce4d6e7b743f5adc27415ce2afceb3b9", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p57jzkf5f27sxe80y6unq780njt8y6mnmwsl44hp8g9ww9t7wkwusv7av76", + "value": 330 + }, + { + "scriptpubkey": "51024e73", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_2 4e73", + "scriptpubkey_type": "unknown", + "scriptpubkey_address": "bc1pfeessrawgf", + "value": 240 + }, + { + "scriptpubkey": "6a224e6f7420796f757220696e707574732c206e6f7420796f7572206f7574707574732e005152535455565758595a5b5c5d5e5f60", + "scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_34 4e6f7420796f757220696e707574732c206e6f7420796f7572206f7574707574732e OP_0 OP_PUSHNUM_1 OP_PUSHNUM_2 OP_PUSHNUM_3 OP_PUSHNUM_4 OP_PUSHNUM_5 OP_PUSHNUM_6 OP_PUSHNUM_7 OP_PUSHNUM_8 OP_PUSHNUM_9 OP_PUSHNUM_10 OP_PUSHNUM_11 OP_PUSHNUM_12 OP_PUSHNUM_13 OP_PUSHNUM_14 OP_PUSHNUM_15 OP_PUSHNUM_16", + "scriptpubkey_type": "op_return", + "value": 0 + } + ], + "size": 3500, + "weight": 8186, + "sigops": 115, + "fee": 71294, + "status": { + "confirmed": true, + "block_height": 850000, + "block_hash": "00000000000000000002a0b5db2a7f8d9087464c2586b546be7bce8eb53b8187", + "block_time": 1719689674 + } } ] \ No newline at end of file diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index a7f447941..050213143 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -20,9 +20,10 @@ describe('Mempool Backend Config', () => { BLOCKS_SUMMARIES_INDEXING: false, GOGGLES_INDEXING: false, HTTP_PORT: 8999, + UNIX_SOCKET_PATH: '', SPAWN_CLUSTER_PROCS: 0, API_URL_PREFIX: '/api/v1/', - AUTOMATIC_BLOCK_REINDEXING: false, + AUTOMATIC_POOLS_UPDATE: false, POLL_RATE_MS: 2000, CACHE_DIR: './cache', CACHE_ENABLED: true, @@ -62,6 +63,7 @@ describe('Mempool Backend Config', () => { REQUEST_TIMEOUT: 10000, FALLBACK_TIMEOUT: 5000, FALLBACK: [], + MAX_BEHIND_TIP: 2, }); expect(config.CORE_RPC).toStrictEqual({ @@ -134,6 +136,8 @@ describe('Mempool Backend Config', () => { ENABLED: false, AUDIT: false, AUDIT_START_HEIGHT: 774000, + STATISTICS: false, + STATISTICS_START_TIME: 1481932800, SERVERS: [] }); @@ -150,6 +154,7 @@ describe('Mempool Backend Config', () => { expect(config.FIAT_PRICE).toStrictEqual({ ENABLED: true, + PAID: false, API_KEY: '', }); }); diff --git a/backend/src/__tests__/gbt/gbt-tests.ts b/backend/src/__tests__/gbt/gbt-tests.ts index 8a3995f71..561963aaa 100644 --- a/backend/src/__tests__/gbt/gbt-tests.ts +++ b/backend/src/__tests__/gbt/gbt-tests.ts @@ -13,7 +13,7 @@ const vectorBuffer: Buffer = fs.readFileSync(path.join(__dirname, './', './test- describe('Rust GBT', () => { test('should produce the same template as getBlockTemplate from Bitcoin Core', async () => { - const rustGbt = new GbtGenerator(); + const rustGbt = new GbtGenerator(4_000_000, 8); const { mempool, maxUid } = mempoolFromArrayBuffer(vectorBuffer.buffer); const result = await rustGbt.make(mempool, [], maxUid); diff --git a/backend/src/api/about.routes.ts b/backend/src/api/about.routes.ts index 5e7d3b70e..2020d111d 100644 --- a/backend/src/api/about.routes.ts +++ b/backend/src/api/about.routes.ts @@ -70,7 +70,7 @@ class AboutRoutes { res.status(500).end(); } }) - .get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username', async (req, res) => { + .get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username/:md5', async (req, res) => { const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; try { const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); diff --git a/backend/src/api/acceleration/acceleration.routes.ts b/backend/src/api/acceleration/acceleration.routes.ts index 69b320171..082d53330 100644 --- a/backend/src/api/acceleration/acceleration.routes.ts +++ b/backend/src/api/acceleration/acceleration.routes.ts @@ -1,55 +1,50 @@ -import { Application, Request, Response } from "express"; -import config from "../../config"; -import axios from "axios"; -import logger from "../../logger"; +import { Application, Request, Response } from 'express'; +import config from '../../config'; +import axios from 'axios'; +import logger from '../../logger'; +import mempool from '../mempool'; +import AccelerationRepository from '../../repositories/AccelerationRepository'; class AccelerationRoutes { private tag = 'Accelerator'; - public initRoutes(app: Application) { + public initRoutes(app: Application): void { app .get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations', this.$getAcceleratorAccelerations.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history', this.$getAcceleratorAccelerationsHistory.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history/aggregated', this.$getAcceleratorAccelerationsHistoryAggregated.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/stats', this.$getAcceleratorAccelerationsStats.bind(this)) + .post(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/estimate', this.$getAcceleratorEstimate.bind(this)) ; } - private async $getAcceleratorAccelerations(req: Request, res: Response) { - const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; - try { - const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); - for (const key in response.headers) { - res.setHeader(key, response.headers[key]); - } - response.data.pipe(res); - } catch (e) { - logger.err(`Unable to get current accelerations from ${url} in $getAcceleratorAccelerations(), ${e}`, this.tag); - res.status(500).end(); - } + private async $getAcceleratorAccelerations(req: Request, res: Response): Promise { + const accelerations = mempool.getAccelerations(); + res.status(200).send(Object.values(accelerations)); } - private async $getAcceleratorAccelerationsHistory(req: Request, res: Response) { - const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; - try { - const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); - for (const key in response.headers) { - res.setHeader(key, response.headers[key]); - } - response.data.pipe(res); - } catch (e) { - logger.err(`Unable to get acceleration history from ${url} in $getAcceleratorAccelerationsHistory(), ${e}`, this.tag); - res.status(500).end(); - } + private async $getAcceleratorAccelerationsHistory(req: Request, res: Response): Promise { + const history = await AccelerationRepository.$getAccelerationInfo(null, req.query.blockHeight ? parseInt(req.query.blockHeight as string, 10) : null); + res.status(200).send(history.map(accel => ({ + txid: accel.txid, + added: accel.added, + status: 'completed', + effectiveFee: accel.effective_fee, + effectiveVsize: accel.effective_vsize, + boostRate: accel.boost_rate, + boostCost: accel.boost_cost, + blockHeight: accel.height, + pools: [accel.pool], + }))); } - private async $getAcceleratorAccelerationsHistoryAggregated(req: Request, res: Response) { + private async $getAcceleratorAccelerationsHistoryAggregated(req: Request, res: Response): Promise { const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; try { const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); for (const key in response.headers) { - res.setHeader(key, response.headers[key]); - } + res.setHeader(key, response.headers[key]); + } response.data.pipe(res); } catch (e) { logger.err(`Unable to get aggregated acceleration history from ${url} in $getAcceleratorAccelerationsHistoryAggregated(), ${e}`, this.tag); @@ -57,19 +52,33 @@ class AccelerationRoutes { } } - private async $getAcceleratorAccelerationsStats(req: Request, res: Response) { + private async $getAcceleratorAccelerationsStats(req: Request, res: Response): Promise { const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; try { const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); for (const key in response.headers) { - res.setHeader(key, response.headers[key]); - } + res.setHeader(key, response.headers[key]); + } response.data.pipe(res); } catch (e) { logger.err(`Unable to get acceleration stats from ${url} in $getAcceleratorAccelerationsStats(), ${e}`, this.tag); res.status(500).end(); } } + + private async $getAcceleratorEstimate(req: Request, res: Response): Promise { + const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; + try { + const response = await axios.post(url, req.body, { responseType: 'stream', timeout: 10000 }); + for (const key in response.headers) { + res.setHeader(key, response.headers[key]); + } + response.data.pipe(res); + } catch (e) { + logger.err(`Unable to get acceleration estimate from ${url} in $getAcceleratorEstimate(), ${e}`, this.tag); + res.status(500).end(); + } + } } export default new AccelerationRoutes(); \ No newline at end of file diff --git a/backend/src/api/acceleration/acceleration.ts b/backend/src/api/acceleration/acceleration.ts index 2dbaa8b07..f26805ff2 100644 --- a/backend/src/api/acceleration/acceleration.ts +++ b/backend/src/api/acceleration/acceleration.ts @@ -1,15 +1,14 @@ import logger from '../../logger'; import { MempoolTransactionExtended } from '../../mempool.interfaces'; -import { IEsploraApi } from '../bitcoin/esplora-api.interface'; +import { GraphTx, getSameBlockRelatives, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from '../mini-miner'; const BLOCK_WEIGHT_UNITS = 4_000_000; -const BLOCK_SIGOPS = 80_000; const MAX_RELATIVE_GRAPH_SIZE = 200; const BID_BOOST_WINDOW = 40_000; const BID_BOOST_MIN_OFFSET = 10_000; const BID_BOOST_MAX_OFFSET = 400_000; -type Acceleration = { +export type Acceleration = { txid: string; max_bid: number; }; @@ -28,31 +27,6 @@ export interface AccelerationInfo { cost: number; // additional cost to accelerate ((cost + txSummary.effectiveFee) / txSummary.effectiveVsize) >= targetFeeRate } -interface GraphTx { - txid: string; - vsize: number; - weight: number; - fees: { - base: number; // in sats - }; - depends: string[]; - spentby: string[]; -} - -interface MempoolTx extends GraphTx { - ancestorcount: number; - ancestorsize: number; - fees: { // in sats - base: number; - ancestor: number; - }; - - ancestors: Map, - ancestorRate: number; - individualRate: number; - score: number; -} - class AccelerationCosts { /** * Takes a list of accelerations and verbose block data @@ -61,7 +35,7 @@ class AccelerationCosts { * @param accelerationsx * @param verboseBlock */ - public calculateBoostRate(accelerations: Acceleration[], blockTxs: IEsploraApi.Transaction[]): number { + public calculateBoostRate(accelerations: Acceleration[], blockTxs: MempoolTransactionExtended[]): number { // Run GBT ourselves to calculate accurate effective fee rates // the list of transactions comes from a mined block, so we already know everything fits within consensus limits const template = makeBlockTemplate(blockTxs, accelerations, 1, Infinity, Infinity); @@ -170,108 +144,28 @@ class AccelerationCosts { /** * Takes an accelerated mined txid and a target rate * Returns the total vsize, fees and acceleration cost (in sats) of the tx and all same-block ancestors - * - * @param txid - * @param medianFeeRate + * + * @param txid + * @param medianFeeRate */ public getAccelerationInfo(tx: MempoolTransactionExtended, targetFeeRate: number, transactions: MempoolTransactionExtended[]): AccelerationInfo { // Get same-block transaction ancestors - const allRelatives = this.getSameBlockRelatives(tx, transactions); - const relativesMap = this.initializeRelatives(allRelatives); - const rootTx = relativesMap.get(tx.txid) as MempoolTx; + const allRelatives = getSameBlockRelatives(tx, transactions); + const relativesMap = initializeRelatives(allRelatives); + const rootTx = relativesMap.get(tx.txid) as GraphTx; // Calculate cost to boost return this.calculateAccelerationAncestors(rootTx, relativesMap, targetFeeRate); } - /** - * Takes a raw transaction, and builds a graph of same-block relatives, - * and returns as a MempoolTx - * - * @param tx - */ - private getSameBlockRelatives(tx: MempoolTransactionExtended, transactions: MempoolTransactionExtended[]): Map { - const blockTxs = new Map(); // map of txs in this block - const spendMap = new Map(); // map of outpoints to spending txids - for (const tx of transactions) { - blockTxs.set(tx.txid, tx); - for (const vin of tx.vin) { - spendMap.set(`${vin.txid}:${vin.vout}`, tx.txid); - } - } - - const relatives: Map = new Map(); - const stack: string[] = [tx.txid]; - - // build set of same-block ancestors - while (stack.length > 0) { - const nextTxid = stack.pop(); - const nextTx = nextTxid ? blockTxs.get(nextTxid) : null; - if (!nextTx || relatives.has(nextTx.txid)) { - continue; - } - - const mempoolTx = this.convertToGraphTx(nextTx); - - mempoolTx.fees.base = nextTx.fee || 0; - mempoolTx.depends = nextTx.vin.map(vin => vin.txid).filter(inTxid => inTxid && blockTxs.has(inTxid)) as string[]; - mempoolTx.spentby = nextTx.vout.map((vout, index) => spendMap.get(`${nextTx.txid}:${index}`)).filter(outTxid => outTxid && blockTxs.has(outTxid)) as string[]; - - for (const txid of [...mempoolTx.depends, ...mempoolTx.spentby]) { - if (txid) { - stack.push(txid); - } - } - - relatives.set(mempoolTx.txid, mempoolTx); - } - - return relatives; - } - - /** - * Takes a raw transaction and converts it to MempoolTx format - * fee and ancestor data is initialized with dummy/null values - * - * @param tx - */ - private convertToGraphTx(tx: MempoolTransactionExtended): GraphTx { - return { - txid: tx.txid, - vsize: Math.ceil(tx.weight / 4), - weight: tx.weight, - fees: { - base: 0, // dummy - }, - depends: [], // dummy - spentby: [], //dummy - }; - } - - private convertGraphToMempoolTx(tx: GraphTx): MempoolTx { - return { - ...tx, - fees: { - base: tx.fees.base, - ancestor: tx.fees.base, - }, - ancestorcount: 1, - ancestorsize: Math.ceil(tx.weight / 4), - ancestors: new Map(), - ancestorRate: 0, - individualRate: 0, - score: 0, - }; - } - /** * Given a root transaction, a list of in-mempool ancestors, and a target fee rate, * Calculate the minimum set of transactions to fee-bump, their total vsize + fees - * + * * @param tx * @param ancestors */ - private calculateAccelerationAncestors(tx: MempoolTx, relatives: Map, targetFeeRate: number): AccelerationInfo { + private calculateAccelerationAncestors(tx: GraphTx, relatives: Map, targetFeeRate: number): AccelerationInfo { // add root tx to the ancestor map relatives.set(tx.txid, tx); @@ -283,12 +177,12 @@ class AccelerationCosts { }); // Initialize individual & ancestor fee rates - relatives.forEach(entry => this.setAncestorScores(entry)); + relatives.forEach(entry => setAncestorScores(entry)); // Sort by descending ancestor score - let sortedRelatives = Array.from(relatives.values()).sort(this.mempoolComparator); + let sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator); - let includedInCluster: Map | null = null; + let includedInCluster: Map | null = null; // While highest score >= targetFeeRate let maxIterations = MAX_RELATIVE_GRAPH_SIZE; @@ -297,17 +191,17 @@ class AccelerationCosts { // Grab the highest scoring entry const best = sortedRelatives.shift(); if (best) { - const cluster = new Map(best.ancestors?.entries() || []); + const cluster = new Map(best.ancestors?.entries() || []); if (best.ancestors.has(tx.txid)) { includedInCluster = cluster; } cluster.set(best.txid, best); // Remove this cluster (it already pays over the target rate, so doesn't need to be boosted) // and update scores, ancestor totals and dependencies for the survivors - this.removeAncestors(cluster, relatives); + removeAncestors(cluster, relatives); // re-sort - sortedRelatives = Array.from(relatives.values()).sort(this.mempoolComparator); + sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator); } } @@ -345,394 +239,6 @@ class AccelerationCosts { nextBlockFee: Math.ceil(tx.ancestorsize * targetFeeRate), }; } - - /** - * Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors - * for each transaction. - * - * @param tx - * @param all - */ - private setAncestors(tx: MempoolTx, all: Map, visited: Map>, depth: number = 0): Map { - // sanity check for infinite recursion / too many ancestors (should never happen) - if (depth >= 100) { - logger.warn('acceleration dependency calculation failed: setAncestors reached depth of 100, unable to proceed', `Accelerator`); - throw new Error('invalid_tx_dependencies'); - } - - // initialize the ancestor map for this tx - tx.ancestors = new Map(); - tx.depends.forEach(parentId => { - const parent = all.get(parentId); - if (parent) { - // add the parent - tx.ancestors?.set(parentId, parent); - // check for a cached copy of this parent's ancestors - let ancestors = visited.get(parent.txid); - if (!ancestors) { - // recursively fetch the parent's ancestors - ancestors = this.setAncestors(parent, all, visited, depth + 1); - } - // and add to this tx's map - ancestors.forEach((ancestor, ancestorId) => { - tx.ancestors?.set(ancestorId, ancestor); - }); - } - }); - visited.set(tx.txid, tx.ancestors); - - return tx.ancestors; - } - - /** - * Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph - * by running setAncestors on each leaf, and caching intermediate results. - * then initializes ancestor data for each transaction - * - * @param all - */ - private initializeRelatives(all: Map): Map { - const mempoolTxs = new Map(); - all.forEach(entry => { - mempoolTxs.set(entry.txid, this.convertGraphToMempoolTx(entry)); - }); - const visited: Map> = new Map(); - const leaves: MempoolTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0); - for (const leaf of leaves) { - this.setAncestors(leaf, mempoolTxs, visited); - } - mempoolTxs.forEach(entry => { - entry.ancestors?.forEach(ancestor => { - entry.ancestorcount++; - entry.ancestorsize += ancestor.vsize; - entry.fees.ancestor += ancestor.fees.base; - }); - this.setAncestorScores(entry); - }); - return mempoolTxs; - } - - /** - * Remove a cluster of transactions from an in-mempool dependency graph - * and update the survivors' scores and ancestors - * - * @param cluster - * @param ancestors - */ - private removeAncestors(cluster: Map, all: Map): void { - // remove - cluster.forEach(tx => { - all.delete(tx.txid); - }); - - // update survivors - all.forEach(tx => { - cluster.forEach(remove => { - if (tx.ancestors?.has(remove.txid)) { - // remove as dependency - tx.ancestors.delete(remove.txid); - tx.depends = tx.depends.filter(parent => parent !== remove.txid); - // update ancestor sizes and fees - tx.ancestorsize -= remove.vsize; - tx.fees.ancestor -= remove.fees.base; - } - }); - // recalculate fee rates - this.setAncestorScores(tx); - }); - } - - /** - * Take a mempool transaction, and set the fee rates and ancestor score - * - * @param tx - */ - private setAncestorScores(tx: MempoolTx): void { - tx.individualRate = tx.fees.base / tx.vsize; - tx.ancestorRate = tx.fees.ancestor / tx.ancestorsize; - tx.score = Math.min(tx.individualRate, tx.ancestorRate); - } - - // Sort by descending score - private mempoolComparator(a, b): number { - return b.score - a.score; - } } -export default new AccelerationCosts; - -interface TemplateTransaction { - txid: string; - order: number; - weight: number; - adjustedVsize: number; // sigop-adjusted vsize, rounded up to the nearest integer - sigops: number; - fee: number; - feeDelta: number; - ancestors: string[]; - cluster: string[]; - effectiveFeePerVsize: number; -} - -interface MinerTransaction extends TemplateTransaction { - inputs: string[]; - feePerVsize: number; - relativesSet: boolean; - ancestorMap: Map; - children: Set; - ancestorFee: number; - ancestorVsize: number; - ancestorSigops: number; - score: number; - used: boolean; - modified: boolean; - dependencyRate: number; -} - -/* -* Build a block using an approximation of the transaction selection algorithm from Bitcoin Core -* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp) -*/ -export function makeBlockTemplate(candidates: IEsploraApi.Transaction[], accelerations: Acceleration[], maxBlocks: number = 8, weightLimit: number = BLOCK_WEIGHT_UNITS, sigopLimit: number = BLOCK_SIGOPS): TemplateTransaction[] { - const auditPool: Map = new Map(); - const mempoolArray: MinerTransaction[] = []; - - candidates.forEach(tx => { - // initializing everything up front helps V8 optimize property access later - const adjustedVsize = Math.ceil(Math.max(tx.weight / 4, 5 * (tx.sigops || 0))); - const feePerVsize = (tx.fee / adjustedVsize); - auditPool.set(tx.txid, { - txid: tx.txid, - order: txidToOrdering(tx.txid), - fee: tx.fee, - feeDelta: 0, - weight: tx.weight, - adjustedVsize, - feePerVsize: feePerVsize, - effectiveFeePerVsize: feePerVsize, - dependencyRate: feePerVsize, - sigops: tx.sigops || 0, - inputs: (tx.vin?.map(vin => vin.txid) || []) as string[], - relativesSet: false, - ancestors: [], - cluster: [], - ancestorMap: new Map(), - children: new Set(), - ancestorFee: 0, - ancestorVsize: 0, - ancestorSigops: 0, - score: 0, - used: false, - modified: false, - }); - mempoolArray.push(auditPool.get(tx.txid) as MinerTransaction); - }); - - // set accelerated effective fee - for (const acceleration of accelerations) { - const tx = auditPool.get(acceleration.txid); - if (tx) { - tx.feeDelta = acceleration.max_bid; - tx.feePerVsize = ((tx.fee + tx.feeDelta) / tx.adjustedVsize); - tx.effectiveFeePerVsize = tx.feePerVsize; - tx.dependencyRate = tx.feePerVsize; - } - } - - // Build relatives graph & calculate ancestor scores - for (const tx of mempoolArray) { - if (!tx.relativesSet) { - setRelatives(tx, auditPool); - } - } - - // Sort by descending ancestor score - mempoolArray.sort(priorityComparator); - - // Build blocks by greedily choosing the highest feerate package - // (i.e. the package rooted in the transaction with the best ancestor score) - const blocks: number[][] = []; - let blockWeight = 0; - let blockSigops = 0; - const transactions: MinerTransaction[] = []; - let modified: MinerTransaction[] = []; - const overflow: MinerTransaction[] = []; - let failures = 0; - while (mempoolArray.length || modified.length) { - // skip invalid transactions - while (mempoolArray[0].used || mempoolArray[0].modified) { - mempoolArray.shift(); - } - - // Select best next package - let nextTx; - const nextPoolTx = mempoolArray[0]; - const nextModifiedTx = modified[0]; - if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) { - nextTx = nextPoolTx; - mempoolArray.shift(); - } else { - modified.shift(); - if (nextModifiedTx) { - nextTx = nextModifiedTx; - } - } - - if (nextTx && !nextTx?.used) { - // Check if the package fits into this block - if (blocks.length >= (maxBlocks - 1) || ((blockWeight + (4 * nextTx.ancestorVsize) < weightLimit) && (blockSigops + nextTx.ancestorSigops <= sigopLimit))) { - const ancestors: MinerTransaction[] = Array.from(nextTx.ancestorMap.values()); - // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) - const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; - const clusterTxids = sortedTxSet.map(tx => tx.txid); - const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / nextTx.ancestorVsize); - const used: MinerTransaction[] = []; - while (sortedTxSet.length) { - const ancestor = sortedTxSet.pop(); - if (!ancestor) { - continue; - } - ancestor.used = true; - ancestor.usedBy = nextTx.txid; - // update this tx with effective fee rate & relatives data - if (ancestor.effectiveFeePerVsize !== effectiveFeeRate) { - ancestor.effectiveFeePerVsize = effectiveFeeRate; - } - ancestor.cluster = clusterTxids; - transactions.push(ancestor); - blockWeight += ancestor.weight; - blockSigops += ancestor.sigops; - used.push(ancestor); - } - - // remove these as valid package ancestors for any descendants remaining in the mempool - if (used.length) { - used.forEach(tx => { - modified = updateDescendants(tx, auditPool, modified, effectiveFeeRate); - }); - } - - failures = 0; - } else { - // hold this package in an overflow list while we check for smaller options - overflow.push(nextTx); - failures++; - } - } - - // this block is full - const exceededPackageTries = failures > 1000 && blockWeight > (weightLimit - 4000); - const queueEmpty = !mempoolArray.length && !modified.length; - - if (exceededPackageTries || queueEmpty) { - break; - } - } - - for (const tx of transactions) { - tx.ancestors = Object.values(tx.ancestorMap); - } - - return transactions; -} - -// traverse in-mempool ancestors -// recursion unavoidable, but should be limited to depth < 25 by mempool policy -function setRelatives( - tx: MinerTransaction, - mempool: Map, -): void { - for (const parent of tx.inputs) { - const parentTx = mempool.get(parent); - if (parentTx && !tx.ancestorMap?.has(parent)) { - tx.ancestorMap.set(parent, parentTx); - parentTx.children.add(tx); - // visit each node only once - if (!parentTx.relativesSet) { - setRelatives(parentTx, mempool); - } - parentTx.ancestorMap.forEach((ancestor) => { - tx.ancestorMap.set(ancestor.txid, ancestor); - }); - } - }; - tx.ancestorFee = (tx.fee + tx.feeDelta); - tx.ancestorVsize = tx.adjustedVsize || 0; - tx.ancestorSigops = tx.sigops || 0; - tx.ancestorMap.forEach((ancestor) => { - tx.ancestorFee += (ancestor.fee + ancestor.feeDelta); - tx.ancestorVsize += ancestor.adjustedVsize; - tx.ancestorSigops += ancestor.sigops; - }); - tx.score = tx.ancestorFee / tx.ancestorVsize; - tx.relativesSet = true; -} - -// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score -// avoids recursion to limit call stack depth -function updateDescendants( - rootTx: MinerTransaction, - mempool: Map, - modified: MinerTransaction[], - clusterRate: number, -): MinerTransaction[] { - const descendantSet: Set = new Set(); - // stack of nodes left to visit - const descendants: MinerTransaction[] = []; - let descendantTx: MinerTransaction | undefined; - rootTx.children.forEach(childTx => { - if (!descendantSet.has(childTx)) { - descendants.push(childTx); - descendantSet.add(childTx); - } - }); - while (descendants.length) { - descendantTx = descendants.pop(); - if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { - // remove tx as ancestor - descendantTx.ancestorMap.delete(rootTx.txid); - descendantTx.ancestorFee -= (rootTx.fee + rootTx.feeDelta); - descendantTx.ancestorVsize -= rootTx.adjustedVsize; - descendantTx.ancestorSigops -= rootTx.sigops; - descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorVsize; - descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate; - - if (!descendantTx.modified) { - descendantTx.modified = true; - modified.push(descendantTx); - } - - // add this node's children to the stack - descendantTx.children.forEach(childTx => { - // visit each node only once - if (!descendantSet.has(childTx)) { - descendants.push(childTx); - descendantSet.add(childTx); - } - }); - } - } - // return new, resorted modified list - return modified.sort(priorityComparator); -} - -// Used to sort an array of MinerTransactions by descending ancestor score -function priorityComparator(a: MinerTransaction, b: MinerTransaction): number { - if (b.score === a.score) { - // tie-break by txid for stability - return a.order - b.order; - } else { - return b.score - a.score; - } -} - -// returns the most significant 4 bytes of the txid as an integer -function txidToOrdering(txid: string): number { - return parseInt( - txid.substring(62, 64) + - txid.substring(60, 62) + - txid.substring(58, 60) + - txid.substring(56, 58), - 16 - ); -} +export default new AccelerationCosts; \ No newline at end of file diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index fb0d05baa..e09234cdc 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -2,24 +2,28 @@ import config from '../config'; import logger from '../logger'; import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; import rbfCache from './rbf-cache'; +import transactionUtils from './transaction-utils'; const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners class Audit { - auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }, useAccelerations: boolean = false) - : { censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } { + auditBlock(height: number, transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }) + : { unseen: string[], censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } { if (!projectedBlocks?.[0]?.transactionIds || !mempool) { - return { censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 }; + return { unseen: [], censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 }; } const matches: string[] = []; // present in both mined block and template const added: string[] = []; // present in mined block, not in template - const prioritized: string[] = [] // present in the mined block, not in the template, but further down in the mempool + const unseen: string[] = []; // present in the mined block, not in our mempool + let prioritized: string[] = []; // higher in the block than would be expected by in-band feerate alone + let deprioritized: string[] = []; // lower in the block than would be expected by in-band feerate alone const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block const accelerated: string[] = []; // prioritized by the mempool accelerator const isCensored = {}; // missing, without excuse const isDisplaced = {}; + const isAccelerated = {}; let displacedWeight = 0; let matchedWeight = 0; let projectedWeight = 0; @@ -32,6 +36,7 @@ class Audit { inBlock[tx.txid] = tx; if (mempool[tx.txid] && mempool[tx.txid].acceleration) { accelerated.push(tx.txid); + isAccelerated[tx.txid] = true; } } // coinbase is always expected @@ -75,10 +80,6 @@ class Audit { let failures = 0; let blockIndex = 1; while (projectedBlocks[blockIndex] && failures < 500) { - if (index >= projectedBlocks[blockIndex].transactionIds.length) { - index = 0; - blockIndex++; - } const txid = projectedBlocks[blockIndex].transactionIds[index]; const tx = mempool[txid]; if (tx) { @@ -102,6 +103,10 @@ class Audit { logger.warn('projected transaction missing from mempool cache'); } index++; + if (index >= projectedBlocks[blockIndex].transactionIds.length) { + index = 0; + blockIndex++; + } } // mark unexpected transactions in the mined block as 'added' @@ -113,11 +118,16 @@ class Audit { } else { if (rbfCache.has(tx.txid)) { rbf.push(tx.txid); - } else if (!isDisplaced[tx.txid]) { + if (!mempool[tx.txid] && !rbfCache.getReplacedBy(tx.txid)) { + unseen.push(tx.txid); + } + } else { if (mempool[tx.txid]) { - prioritized.push(tx.txid); + if (isDisplaced[tx.txid]) { + added.push(tx.txid); + } } else { - added.push(tx.txid); + unseen.push(tx.txid); } } overflowWeight += tx.weight; @@ -125,6 +135,8 @@ class Audit { totalWeight += tx.weight; } + ({ prioritized, deprioritized } = transactionUtils.identifyPrioritizedTransactions(transactions, 'effectiveFeePerVsize')); + // transactions missing from near the end of our template are probably not being censored let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight); let maxOverflowRate = 0; @@ -165,6 +177,7 @@ class Audit { const similarity = projectedWeight ? matchedWeight / projectedWeight : 1; return { + unseen, censored: Object.keys(isCensored), added, prioritized, diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index cc0c801b5..a08f43238 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -1,4 +1,4 @@ -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { @@ -22,11 +22,13 @@ export interface AbstractBitcoinApi { $getScriptHash(scripthash: string): Promise; $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise; $sendRawTransaction(rawTransaction: string): Promise; + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise; $getOutspend(txId: string, vout: number): Promise; $getOutspends(txId: string): Promise; $getBatchedOutspends(txId: string[]): Promise; $getBatchedOutspendsInternal(txId: string[]): Promise; $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise; + $getCoinbaseTx(blockhash: string): Promise; startHealthChecks(): void; getHealthStatus(): HealthCheckHost[]; diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index e176566d7..6e8583f6f 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -205,3 +205,16 @@ export namespace IBitcoinApi { "utxo_size_inc": number; } } + +export interface TestMempoolAcceptResult { + txid: string, + wtxid: string, + allowed?: boolean, + vsize?: number, + fees?: { + base: number, + "effective-feerate": number, + "effective-includes": string[], + }, + ['reject-reason']?: string, +} diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index d19eb06ac..7fa431db6 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -1,6 +1,6 @@ import * as bitcoinjs from 'bitcoinjs-lib'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; import blocks from '../blocks'; import mempool from '../mempool'; @@ -107,8 +107,14 @@ class BitcoinApi implements AbstractBitcoinApi { .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); } - $getTxsForBlock(hash: string): Promise { - throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.'); + async $getTxsForBlock(hash: string): Promise { + const verboseBlock: IBitcoinApi.VerboseBlock = await this.bitcoindClient.getBlock(hash, 2); + const transactions: IEsploraApi.Transaction[] = []; + for (const tx of verboseBlock.tx) { + const converted = await this.$convertTransaction(tx, true); + transactions.push(converted); + } + return transactions; } $getRawBlock(hash: string): Promise { @@ -159,13 +165,21 @@ class BitcoinApi implements AbstractBitcoinApi { const mp = mempool.getMempool(); for (const tx in mp) { for (const vout of mp[tx].vout) { - if (vout.scriptpubkey_address.indexOf(prefix) === 0) { + if (vout.scriptpubkey_address?.indexOf(prefix) === 0) { found[vout.scriptpubkey_address] = ''; if (Object.keys(found).length >= 10) { return Object.keys(found); } } } + for (const vin of mp[tx].vin) { + if (vin.prevout?.scriptpubkey_address?.indexOf(prefix) === 0) { + found[vin.prevout?.scriptpubkey_address] = ''; + if (Object.keys(found).length >= 10) { + return Object.keys(found); + } + } + } } return Object.keys(found); } @@ -174,6 +188,14 @@ class BitcoinApi implements AbstractBitcoinApi { return this.bitcoindClient.sendRawTransaction(rawTransaction); } + async $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + if (rawTransactions.length) { + return this.bitcoindClient.testMempoolAccept(rawTransactions, maxfeerate ?? undefined); + } else { + return []; + } + } + async $getOutspend(txId: string, vout: number): Promise { const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); return { @@ -224,6 +246,11 @@ class BitcoinApi implements AbstractBitcoinApi { return outspends; } + async $getCoinbaseTx(blockhash: string): Promise { + const txids = await this.$getTxIdsForBlock(blockhash); + return this.$getRawTransaction(txids[0]); + } + $getEstimatedHashrate(blockHeight: number): Promise { // 120 is the default block span in Core return this.bitcoindClient.getNetworkHashPs(120, blockHeight); @@ -296,6 +323,7 @@ class BitcoinApi implements AbstractBitcoinApi { 'witness_v1_taproot': 'v1_p2tr', 'nonstandard': 'nonstandard', 'multisig': 'multisig', + 'anchor': 'anchor', 'nulldata': 'op_return' }; diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 293af3602..8140a778a 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -19,7 +19,7 @@ import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; import transactionRepository from '../../repositories/TransactionRepository'; import rbfCache from '../rbf-cache'; -import { calculateCpfp } from '../cpfp'; +import { calculateMempoolTxCpfp } from '../cpfp'; import BlocksRepository from '../../repositories/BlocksRepository'; class BitcoinRoutes { @@ -43,6 +43,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/tx/:txid/audit', this.$getBlockTxAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) @@ -56,6 +57,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mempool/recent', this.getRecentMempoolTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId', this.getTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'tx', this.$postTransaction) + .post(config.MEMPOOL.API_URL_PREFIX + 'txs/test', this.$testTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) @@ -238,13 +240,17 @@ class BitcoinRoutes { descendants: tx.descendants || null, effectiveFeePerVsize: tx.effectiveFeePerVsize || null, sigops: tx.sigops, + fee: tx.fee, adjustedVsize: tx.adjustedVsize, - acceleration: tx.acceleration + acceleration: tx.acceleration, + acceleratedBy: tx.acceleratedBy || undefined, + acceleratedAt: tx.acceleratedAt || undefined, + feeDelta: tx.feeDelta || undefined, }); return; } - const cpfpInfo = calculateCpfp(tx, mempool.getMempool()); + const cpfpInfo = calculateMempoolTxCpfp(tx, mempool.getMempool()); res.json(cpfpInfo); return; @@ -438,6 +444,20 @@ class BitcoinRoutes { } } + private async $getBlockTxAuditSummary(req: Request, res: Response) { + try { + const auditSummary = await blocks.$getBlockTxAuditSummary(req.params.hash, req.params.txid); + if (auditSummary) { + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(auditSummary); + } else { + return res.status(404).send(`transaction audit not available`); + } + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getBlocks(req: Request, res: Response) { try { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin @@ -829,6 +849,19 @@ class BitcoinRoutes { } } + private async $testTransactions(req: Request, res: Response) { + try { + const rawTxs = Common.getTransactionsFromRequest(req); + const maxfeerate = parseFloat(req.query.maxfeerate as string); + const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); + res.send(result); + } catch (e: any) { + res.setHeader('content-type', 'text/plain'); + res.status(400).send(e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) + : (e.message || 'Error')); + } + } + } export default new BitcoinRoutes(); diff --git a/backend/src/api/bitcoin/esplora-api.interface.ts b/backend/src/api/bitcoin/esplora-api.interface.ts index 0a0960e46..6e6860a41 100644 --- a/backend/src/api/bitcoin/esplora-api.interface.ts +++ b/backend/src/api/bitcoin/esplora-api.interface.ts @@ -54,7 +54,7 @@ export namespace IEsploraApi { scriptpubkey: string; scriptpubkey_asm: string; scriptpubkey_type: string; - scriptpubkey_address: string; + scriptpubkey_address?: string; value: number; // Elements valuecommitment?: number; diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index a9dadf4a0..b4ae35da9 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -5,6 +5,7 @@ import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-fact import { IEsploraApi } from './esplora-api.interface'; import logger from '../../logger'; import { Common } from '../common'; +import { TestMempoolAcceptResult } from './bitcoin-api.interface'; interface FailoverHost { host: string, @@ -24,6 +25,7 @@ interface FailoverHost { class FailoverRouter { activeHost: FailoverHost; fallbackHost: FailoverHost; + maxSlippage: number = config.ESPLORA.MAX_BEHIND_TIP ?? 2; maxHeight: number = 0; hosts: FailoverHost[]; multihost: boolean; @@ -92,13 +94,13 @@ class FailoverRouter { ); if (result) { const height = result.data; - this.maxHeight = Math.max(height, this.maxHeight); + host.latestHeight = height; + this.maxHeight = Math.max(height || 0, ...this.hosts.map(h => (!(h.unreachable || h.timedOut || h.outOfSync) ? h.latestHeight || 0 : 0))); const rtt = result.config['meta'].rtt; host.rtts.unshift(rtt); host.rtts.slice(0, 5); host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length; - host.latestHeight = height; - if (height == null || isNaN(height) || (this.maxHeight - height > 2)) { + if (height == null || isNaN(height) || (this.maxHeight - height > this.maxSlippage)) { host.outOfSync = true; } else { host.outOfSync = false; @@ -125,7 +127,6 @@ class FailoverRouter { host.checked = true; host.lastChecked = Date.now(); - // switch if the current host is out of sync or significantly slower than the next best alternative const rankOrder = this.sortHosts(); // switch if the current host is out of sync or significantly slower than the next best alternative if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== rankOrder[0] && rankOrder[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (rankOrder[0].rtt * 2) + 50)) { @@ -183,7 +184,6 @@ class FailoverRouter { // depose the active host and choose the next best replacement private electHost(): void { - this.activeHost.outOfSync = true; this.activeHost.failures = 0; const rankOrder = this.sortHosts(); this.activeHost = rankOrder[0]; @@ -194,6 +194,7 @@ class FailoverRouter { host.failures++; if (host.failures > 5 && this.multihost) { logger.warn(`🚨🚨🚨 Too many esplora failures on ${this.activeHost.host}, falling back to next best alternative 🚨🚨🚨`); + this.activeHost.unreachable = true; this.electHost(); return this.activeHost; } else { @@ -327,6 +328,10 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method not implemented.'); } + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + throw new Error('Method not implemented.'); + } + $getOutspend(txId: string, vout: number): Promise { return this.failoverRouter.$get('/tx/' + txId + '/outspend/' + vout); } @@ -347,6 +352,11 @@ class ElectrsApi implements AbstractBitcoinApi { return this.failoverRouter.$post('/internal/txs/outspends/by-outpoint', outpoints.map(out => `${out.txid}:${out.vout}`), 'json'); } + async $getCoinbaseTx(blockhash: string): Promise { + const txid = await this.failoverRouter.$get(`/block/${blockhash}/txid/0`); + return this.failoverRouter.$get('/tx/' + txid); + } + public startHealthChecks(): void { this.failoverRouter.startHealthChecks(); } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 28ca38152..306179ca5 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -2,7 +2,7 @@ import config from '../config'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import logger from '../logger'; import memPool from './mempool'; -import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit } from '../mempool.interfaces'; +import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit, TransactionAudit } from '../mempool.interfaces'; import { Common } from './common'; import diskCache from './disk-cache'; import transactionUtils from './transaction-utils'; @@ -29,6 +29,11 @@ import websocketHandler from './websocket-handler'; import redisCache from './redis-cache'; import rbfCache from './rbf-cache'; import { calcBitsDifference } from './difficulty-adjustment'; +import AccelerationRepository from '../repositories/AccelerationRepository'; +import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp'; +import mempool from './mempool'; +import CpfpRepository from '../repositories/CpfpRepository'; +import accelerationApi from './services/acceleration'; class Blocks { private blocks: BlockExtended[] = []; @@ -214,10 +219,10 @@ class Blocks { }; } - public summarizeBlockTransactions(hash: string, transactions: TransactionExtended[]): BlockSummary { + public summarizeBlockTransactions(hash: string, height: number, transactions: TransactionExtended[]): BlockSummary { return { id: hash, - transactions: Common.classifyTransactions(transactions), + transactions: Common.classifyTransactions(transactions, height), }; } @@ -294,10 +299,12 @@ class Blocks { extras.virtualSize = block.weight / 4.0; if (coinbaseTx?.vout.length > 0) { extras.coinbaseAddress = coinbaseTx.vout[0].scriptpubkey_address ?? null; + extras.coinbaseAddresses = [...new Set(coinbaseTx.vout.map(v => v.scriptpubkey_address).filter(a => a) as string[])]; extras.coinbaseSignature = coinbaseTx.vout[0].scriptpubkey_asm ?? null; extras.coinbaseSignatureAscii = transactionUtils.hex2ascii(coinbaseTx.vin[0].scriptsig) ?? null; } else { extras.coinbaseAddress = null; + extras.coinbaseAddresses = null; extras.coinbaseSignature = null; extras.coinbaseSignatureAscii = null; } @@ -369,8 +376,7 @@ class Blocks { } } - const asciiScriptSig = transactionUtils.hex2ascii(txMinerInfo.vin[0].scriptsig); - const addresses = txMinerInfo.vout.map((vout) => vout.scriptpubkey_address).filter((address) => address); + const addresses = txMinerInfo.vout.map((vout) => vout.scriptpubkey_address).filter(address => address) as string[]; let pools: PoolTag[] = []; if (config.DATABASE.ENABLED === true) { @@ -379,26 +385,9 @@ class Blocks { pools = poolsParser.miningPools; } - for (let i = 0; i < pools.length; ++i) { - if (addresses.length) { - const poolAddresses: string[] = typeof pools[i].addresses === 'string' ? - JSON.parse(pools[i].addresses) : pools[i].addresses; - for (let y = 0; y < poolAddresses.length; y++) { - if (addresses.indexOf(poolAddresses[y]) !== -1) { - return pools[i]; - } - } - } - - const regexes: string[] = typeof pools[i].regexes === 'string' ? - JSON.parse(pools[i].regexes) : pools[i].regexes; - for (let y = 0; y < regexes.length; ++y) { - const regex = new RegExp(regexes[y], 'i'); - const match = asciiScriptSig.match(regex); - if (match !== null) { - return pools[i]; - } - } + const pool = poolsParser.matchBlockMiner(txMinerInfo.vin[0].scriptsig, addresses || [], pools); + if (pool) { + return pool; } if (config.DATABASE.ENABLED === true) { @@ -451,7 +440,7 @@ class Blocks { if (config.MEMPOOL.BACKEND === 'esplora') { - const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); + const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendMempoolTransaction(tx)); const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); if (cpfpSummary) { await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary @@ -582,8 +571,11 @@ class Blocks { const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const currentBlockHeight = blockchainInfo.blocks; - const unclassifiedBlocksList = await BlocksSummariesRepository.$getSummariesWithVersion(0); - const unclassifiedTemplatesList = await BlocksSummariesRepository.$getTemplatesWithVersion(0); + const targetSummaryVersion: number = 1; + const targetTemplateVersion: number = 1; + + const unclassifiedBlocksList = await BlocksSummariesRepository.$getSummariesBelowVersion(targetSummaryVersion); + const unclassifiedTemplatesList = await BlocksSummariesRepository.$getTemplatesBelowVersion(targetTemplateVersion); // nothing to do if (!unclassifiedBlocksList?.length && !unclassifiedTemplatesList?.length) { @@ -616,16 +608,24 @@ class Blocks { for (let height = currentBlockHeight; height >= 0; height--) { try { - let txs: TransactionExtended[] | null = null; + let txs: MempoolTransactionExtended[] | null = null; if (unclassifiedBlocks[height]) { const blockHash = unclassifiedBlocks[height]; // fetch transactions - txs = (await bitcoinApi.$getTxsForBlock(blockHash)).map(tx => transactionUtils.extendTransaction(tx)) || []; + txs = (await bitcoinApi.$getTxsForBlock(blockHash)).map(tx => transactionUtils.extendMempoolTransaction(tx)) || []; // add CPFP - const cpfpSummary = Common.calculateCpfp(height, txs, true); + const cpfpSummary = calculateGoodBlockCpfp(height, txs, []); // classify - const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); - await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 1); + const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions); + await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2); + if (unclassifiedBlocks[height].version < 2 && targetSummaryVersion === 2) { + const cpfpClusters = await CpfpRepository.$getClustersAt(height); + if (!cpfpRepository.compareClusters(cpfpClusters, cpfpSummary.clusters)) { + // CPFP clusters changed - update the compact_cpfp tables + await CpfpRepository.$deleteClustersAt(height); + await this.$saveCpfp(blockHash, height, cpfpSummary); + } + } await Common.sleep$(250); } if (unclassifiedTemplates[height]) { @@ -651,9 +651,9 @@ class Blocks { } templateTxs.push(tx || templateTx); } - const cpfpSummary = Common.calculateCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as TransactionExtended[], true); + const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []); // classify - const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); + const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions); const classifiedTxMap: { [txid: string]: TransactionClassified } = {}; for (const tx of classifiedTxs) { classifiedTxMap[tx.txid] = tx; @@ -689,6 +689,52 @@ class Blocks { this.classifyingBlocks = false; } + /** + * [INDEXING] Index missing coinbase addresses for all blocks + */ + public async $indexCoinbaseAddresses(): Promise { + try { + // Get all indexed block hash + const unindexedBlocks = await blocksRepository.$getBlocksWithoutCoinbaseAddresses(); + + if (!unindexedBlocks?.length) { + return; + } + + logger.info(`Indexing missing coinbase addresses for ${unindexedBlocks.length} blocks`); + + // Logging + let count = 0; + let countThisRun = 0; + let timer = Date.now() / 1000; + const startedAt = Date.now() / 1000; + for (const { height, hash } of unindexedBlocks) { + // Logging + const elapsedSeconds = (Date.now() / 1000) - timer; + if (elapsedSeconds > 5) { + const runningFor = (Date.now() / 1000) - startedAt; + const blockPerSeconds = countThisRun / elapsedSeconds; + const progress = Math.round(count / unindexedBlocks.length * 10000) / 100; + logger.debug(`Indexing coinbase addresses for #${height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlocks.length} (${progress}%) | elapsed: ${runningFor.toFixed(2)} seconds`); + timer = Date.now() / 1000; + countThisRun = 0; + } + + const coinbaseTx = await bitcoinApi.$getCoinbaseTx(hash); + const addresses = new Set(coinbaseTx.vout.map(v => v.scriptpubkey_address).filter(a => a) as string[]); + await blocksRepository.$saveCoinbaseAddresses(hash, [...addresses]); + + // Logging + count++; + countThisRun++; + } + logger.notice(`coinbase addresses indexing completed: indexed ${count} blocks`); + } catch (e) { + logger.err(`coinbase addresses indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`); + throw e; + } + } + /** * [INDEXING] Index all blocks metadata for the mining dashboard */ @@ -838,8 +884,11 @@ class Blocks { } else { this.currentBlockHeight++; logger.debug(`New block found (#${this.currentBlockHeight})!`); - this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`); - await chainTips.updateOrphanedBlocks(); + // skip updating the orphan block cache if we've fallen behind the chain tip + if (this.currentBlockHeight >= blockHeightTip - 2) { + this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`); + await chainTips.updateOrphanedBlocks(); + } } this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`); @@ -856,9 +905,14 @@ class Blocks { } } - const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); + let accelerations = Object.values(mempool.getAccelerations()); + if (accelerations?.length > 0) { + const pool = await this.$findBlockMiner(transactionUtils.stripCoinbaseTransaction(transactions[0])); + accelerations = accelerations.filter(a => a.pools.includes(pool.uniqueId)); + } + const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, accelerations.map(a => ({ txid: a.txid, max_bid: a.feeDelta }))); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); - const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); + const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, block.height, cpfpSummary.transactions); this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`); if (Common.indexingEnabled()) { @@ -872,18 +926,19 @@ class Blocks { await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10); await HashratesRepository.$deleteLastEntries(); await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10); + await AccelerationRepository.$deleteAccelerationsFrom(lastBlock.height - 10); this.blocks = this.blocks.slice(0, -10); this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`); for (let i = 10; i >= 0; --i) { const newBlock = await this.$indexBlock(lastBlock.height - i); this.blocks.push(newBlock); this.updateTimerProgress(timer, `reindexed block`); - let cpfpSummary; + let newCpfpSummary; if (config.MEMPOOL.CPFP_INDEXING) { - cpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i); + newCpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i); this.updateTimerProgress(timer, `reindexed block cpfp`); } - await this.$getStrippedBlockTransactions(newBlock.id, true, true, cpfpSummary, newBlock.height); + await this.$getStrippedBlockTransactions(newBlock.id, true, true, newCpfpSummary, newBlock.height); this.updateTimerProgress(timer, `reindexed block summary`); } await mining.$indexDifficultyAdjustments(); @@ -932,7 +987,7 @@ class Blocks { // start async callbacks this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`); - const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions)); + const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, cpfpSummary.transactions)); if (block.height % 2016 === 0) { if (Common.indexingEnabled()) { @@ -974,6 +1029,9 @@ class Blocks { if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); } + blockSummary.transactions.forEach(tx => { + delete tx.acc; + }); this.blockSummaries.push(blockSummary); if (this.blockSummaries.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { this.blockSummaries = this.blockSummaries.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); @@ -1111,12 +1169,13 @@ class Blocks { transactions: cpfpSummary.transactions.map(tx => { let flags: number = 0; try { - flags = tx.flags || Common.getTransactionFlags(tx); + flags = Common.getTransactionFlags(tx, height); } catch (e) { logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e)); } return { txid: tx.txid, + time: tx.firstSeen, fee: tx.fee || 0, vsize: tx.vsize, value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), @@ -1125,11 +1184,11 @@ class Blocks { }; }), }; - summaryVersion = 1; + summaryVersion = cpfpSummary.version; } else { if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); - summary = this.summarizeBlockTransactions(hash, txs); + summary = this.summarizeBlockTransactions(hash, height || 0, txs); summaryVersion = 1; } else { // Call Core RPC @@ -1250,6 +1309,7 @@ class Blocks { utxoset_size: block.extras.utxoSetSize ?? null, coinbase_raw: block.extras.coinbaseRaw ?? null, coinbase_address: block.extras.coinbaseAddress ?? null, + coinbase_addresses: block.extras.coinbaseAddresses ?? null, coinbase_signature: block.extras.coinbaseSignature ?? null, coinbase_signature_ascii: block.extras.coinbaseSignatureAscii ?? null, pool_slug: block.extras.pool.slug ?? null, @@ -1264,7 +1324,7 @@ class Blocks { let summaryVersion = 0; if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx)); - summary = this.summarizeBlockTransactions(cleanBlock.hash, txs); + summary = this.summarizeBlockTransactions(cleanBlock.hash, cleanBlock.height, txs); summaryVersion = 1; } else { // Call Core RPC @@ -1319,6 +1379,14 @@ class Blocks { } } + public async $getBlockTxAuditSummary(hash: string, txid: string): Promise { + if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { + return BlocksAuditsRepository.$getBlockTxAudit(hash, txid); + } else { + return null; + } + } + public getLastDifficultyAdjustmentTime(): number { return this.lastDifficultyAdjustmentTime; } @@ -1335,11 +1403,11 @@ class Blocks { return this.currentBlockHeight; } - public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise { + public async $indexCPFP(hash: string, height: number, txs?: MempoolTransactionExtended[]): Promise { let transactions = txs; if (!transactions) { if (config.MEMPOOL.BACKEND === 'esplora') { - transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); + transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendMempoolTransaction(tx)); } if (!transactions) { const block = await bitcoinClient.getBlock(hash, 2); @@ -1351,7 +1419,7 @@ class Blocks { } if (transactions?.length != null) { - const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); + const summary = calculateFastBlockCpfp(height, transactions); await this.$saveCpfp(hash, height, summary); diff --git a/backend/src/api/chain-tips.ts b/backend/src/api/chain-tips.ts index b68b0b281..b7fd05ad8 100644 --- a/backend/src/api/chain-tips.ts +++ b/backend/src/api/chain-tips.ts @@ -12,32 +12,68 @@ export interface OrphanedBlock { height: number; hash: string; status: 'valid-fork' | 'valid-headers' | 'headers-only'; + prevhash: string; } class ChainTips { private chainTips: ChainTip[] = []; - private orphanedBlocks: OrphanedBlock[] = []; + private orphanedBlocks: { [hash: string]: OrphanedBlock } = {}; + private blockCache: { [hash: string]: OrphanedBlock } = {}; + private orphansByHeight: { [height: number]: OrphanedBlock[] } = {}; public async updateOrphanedBlocks(): Promise { try { this.chainTips = await bitcoinClient.getChainTips(); - this.orphanedBlocks = []; + + const start = Date.now(); + const breakAt = start + 10000; + let newOrphans = 0; + this.orphanedBlocks = {}; for (const chain of this.chainTips) { if (chain.status === 'valid-fork' || chain.status === 'valid-headers') { - let block = await bitcoinClient.getBlock(chain.hash); - while (block && block.confirmations === -1) { - this.orphanedBlocks.push({ - height: block.height, - hash: block.hash, - status: chain.status - }); - block = await bitcoinClient.getBlock(block.previousblockhash); + const orphans: OrphanedBlock[] = []; + let hash = chain.hash; + do { + let orphan = this.blockCache[hash]; + if (!orphan) { + const block = await bitcoinClient.getBlock(hash); + if (block && block.confirmations === -1) { + newOrphans++; + orphan = { + height: block.height, + hash: block.hash, + status: chain.status, + prevhash: block.previousblockhash, + }; + this.blockCache[hash] = orphan; + } + } + if (orphan) { + orphans.push(orphan); + } + hash = orphan?.prevhash; + } while (hash && (Date.now() < breakAt)); + for (const orphan of orphans) { + this.orphanedBlocks[orphan.hash] = orphan; } } + if (Date.now() >= breakAt) { + logger.debug(`Breaking orphaned blocks updater after 10s, will continue next block`); + break; + } } - logger.debug(`Updated orphaned blocks cache. Found ${this.orphanedBlocks.length} orphaned blocks`); + this.orphansByHeight = {}; + const allOrphans = Object.values(this.orphanedBlocks); + for (const orphan of allOrphans) { + if (!this.orphansByHeight[orphan.height]) { + this.orphansByHeight[orphan.height] = []; + } + this.orphansByHeight[orphan.height].push(orphan); + } + + logger.debug(`Updated orphaned blocks cache. Fetched ${newOrphans} new orphaned blocks. Total ${allOrphans.length}`); } catch (e) { logger.err(`Cannot get fetch orphaned blocks. Reason: ${e instanceof Error ? e.message : e}`); } @@ -48,13 +84,7 @@ class ChainTips { return []; } - const orphans: OrphanedBlock[] = []; - for (const block of this.orphanedBlocks) { - if (block.height === height) { - orphans.push(block); - } - } - return orphans; + return this.orphansByHeight[height] || []; } } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 92dfceb52..d17068a09 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,6 +1,6 @@ import * as bitcoinjs from 'bitcoinjs-lib'; import { Request } from 'express'; -import { CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces'; +import { EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces'; import config from '../config'; import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { isIP } from 'net'; @@ -10,7 +10,6 @@ import logger from '../logger'; import { getVarIntLength, opcodes, parseMultisigScript } from '../utils/bitcoin-script'; // Bitcoin Core default policy settings -const TX_MAX_STANDARD_VERSION = 2; const MAX_STANDARD_TX_WEIGHT = 400_000; const MAX_BLOCK_SIGOPS_COST = 80_000; const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5); @@ -200,10 +199,13 @@ export class Common { * * returns true early if any standardness rule is violated, otherwise false * (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced) + * + * As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks. + * For now, just pull out individual rules into versioned functions where necessary. */ - static isNonStandard(tx: TransactionExtended): boolean { + static isNonStandard(tx: TransactionExtended, height?: number): boolean { // version - if (tx.version > TX_MAX_STANDARD_VERSION) { + if (this.isNonStandardVersion(tx, height)) { return true; } @@ -250,6 +252,8 @@ export class Common { } } else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) { return true; + } else if (this.isNonStandardAnchor(tx, height)) { + return true; } // TODO: bad-witness-nonstandard } @@ -258,9 +262,15 @@ export class Common { let opreturnCount = 0; for (const vout of tx.vout) { // scriptpubkey - if (['unknown', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) { + if (['nonstandard', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) { // (non-standard output type) return true; + } else if (vout.scriptpubkey_type === 'unknown') { + // undefined segwit version/length combinations are actually standard in outputs + // https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/interpreter.cpp#L1950-L1951 + if (vout.scriptpubkey.startsWith('00') || !this.isWitnessProgram(vout.scriptpubkey)) { + return true; + } } else if (vout.scriptpubkey_type === 'multisig') { if (!DEFAULT_PERMIT_BAREMULTISIG) { // bare-multisig @@ -286,7 +296,7 @@ export class Common { dustSize += getVarIntLength(dustSize); // add value size dustSize += 8; - if (['v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(vout.scriptpubkey_type)) { + if (Common.isWitnessProgram(vout.scriptpubkey)) { dustSize += 67; } else { dustSize += 148; @@ -308,6 +318,70 @@ export class Common { return false; } + // A witness program is any valid scriptpubkey that consists of a 1-byte push opcode + // followed by a data push between 2 and 40 bytes. + // https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/script.cpp#L224-L240 + static isWitnessProgram(scriptpubkey: string): false | { version: number, program: string } { + if (scriptpubkey.length < 8 || scriptpubkey.length > 84) { + return false; + } + const version = parseInt(scriptpubkey.slice(0,2), 16); + if (version !== 0 && version < 0x51 || version > 0x60) { + return false; + } + const push = parseInt(scriptpubkey.slice(2,4), 16); + if (push + 2 === (scriptpubkey.length / 2)) { + return { + version: version ? version - 0x50 : 0, + program: scriptpubkey.slice(4), + }; + } + return false; + } + + // Individual versioned standardness rules + + static V3_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, + }; + static isNonStandardVersion(tx: TransactionExtended, height?: number): boolean { + let TX_MAX_STANDARD_VERSION = 3; + if ( + height != null + && this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + && height <= this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + ) { + // V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + TX_MAX_STANDARD_VERSION = 2; + } + + if (tx.version > TX_MAX_STANDARD_VERSION) { + return true; + } + return false; + } + + static ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, + }; + static isNonStandardAnchor(tx: TransactionExtended, height?: number): boolean { + if ( + height != null + && this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + && height <= this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + ) { + // anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + return true; + } + return false; + } + static getNonWitnessSize(tx: TransactionExtended): number { let weight = tx.weight; let hasWitness = false; @@ -388,16 +462,19 @@ export class Common { return flags; } - static getTransactionFlags(tx: TransactionExtended): number { + static getTransactionFlags(tx: TransactionExtended, height?: number): number { let flags = tx.flags ? BigInt(tx.flags) : 0n; // Update variable flags (CPFP, RBF) + flags &= ~TransactionFlags.cpfp_child; if (tx.ancestors?.length) { flags |= TransactionFlags.cpfp_child; } + flags &= ~TransactionFlags.cpfp_parent; if (tx.descendants?.length) { flags |= TransactionFlags.cpfp_parent; } + flags &= ~TransactionFlags.replacement; if (tx.replacement) { flags |= TransactionFlags.replacement; } @@ -433,11 +510,10 @@ export class Common { case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; case 'v1_p2tr': { - if (!vin.witness?.length) { - throw new Error('Taproot input missing witness data'); - } flags |= TransactionFlags.p2tr; - flags = Common.isInscription(vin, flags); + if (vin.witness?.length) { + flags = Common.isInscription(vin, flags); + } } break; } } else { @@ -519,7 +595,7 @@ export class Common { if (hasFakePubkey) { flags |= TransactionFlags.fake_pubkey; } - + // fast but bad heuristic to detect possible coinjoins // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1; @@ -535,17 +611,17 @@ export class Common { flags |= TransactionFlags.batch_payout; } - if (this.isNonStandard(tx)) { + if (this.isNonStandard(tx, height)) { flags |= TransactionFlags.nonstandard; } return Number(flags); } - static classifyTransaction(tx: TransactionExtended): TransactionClassified { + static classifyTransaction(tx: TransactionExtended, height?: number): TransactionClassified { let flags = 0; try { - flags = Common.getTransactionFlags(tx); + flags = Common.getTransactionFlags(tx, height); } catch (e) { logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e)); } @@ -556,8 +632,8 @@ export class Common { }; } - static classifyTransactions(txs: TransactionExtended[]): TransactionClassified[] { - return txs.map(Common.classifyTransaction); + static classifyTransactions(txs: TransactionExtended[], height?: number): TransactionClassified[] { + return txs.map(tx => Common.classifyTransaction(tx, height)); } static stripTransaction(tx: TransactionExtended): TransactionStripped { @@ -780,96 +856,6 @@ export class Common { } } - static calculateCpfp(height: number, transactions: TransactionExtended[], saveRelatives: boolean = false): CpfpSummary { - const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block - const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster - let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster - let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root - const txMap: { [txid: string]: TransactionExtended } = {}; - // initialize the txMap - for (const tx of transactions) { - txMap[tx.txid] = tx; - } - // reverse pass to identify CPFP clusters - for (let i = transactions.length - 1; i >= 0; i--) { - const tx = transactions[i]; - if (!ancestors[tx.txid]) { - let totalFee = 0; - let totalVSize = 0; - clusterTxs.forEach(tx => { - totalFee += tx?.fee || 0; - totalVSize += (tx.weight / 4); - }); - const effectiveFeePerVsize = totalFee / totalVSize; - let cluster: CpfpCluster; - if (clusterTxs.length > 1) { - cluster = { - root: clusterTxs[0].txid, - height, - txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }), - effectiveFeePerVsize, - }; - clusters.push(cluster); - } - clusterTxs.forEach(tx => { - txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize; - if (cluster) { - clusterMap[tx.txid] = cluster; - } - }); - // reset working vars - clusterTxs = []; - ancestors = {}; - } - clusterTxs.push(tx); - tx.vin.forEach(vin => { - ancestors[vin.txid] = true; - }); - } - // forward pass to enforce ancestor rate caps - for (const tx of transactions) { - let minAncestorRate = tx.effectiveFeePerVsize; - for (const vin of tx.vin) { - if (txMap[vin.txid]?.effectiveFeePerVsize) { - minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize); - } - } - // check rounded values to skip cases with almost identical fees - const roundedMinAncestorRate = Math.ceil(minAncestorRate); - const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize); - if (roundedMinAncestorRate < roundedEffectiveFeeRate) { - tx.effectiveFeePerVsize = minAncestorRate; - if (!clusterMap[tx.txid]) { - // add a single-tx cluster to record the dependent rate - const cluster = { - root: tx.txid, - height, - txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }], - effectiveFeePerVsize: minAncestorRate, - }; - clusterMap[tx.txid] = cluster; - clusters.push(cluster); - } else { - // update the existing cluster with the dependent rate - clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate; - } - } - } - if (saveRelatives) { - for (const cluster of clusters) { - cluster.txs.forEach((member, index) => { - txMap[member.txid].descendants = cluster.txs.slice(0, index).reverse(); - txMap[member.txid].ancestors = cluster.txs.slice(index + 1).reverse(); - txMap[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize; - }); - } - } - return { - transactions, - clusters, - }; - } - static calcEffectiveFeeStatistics(transactions: { weight: number, fee: number, effectiveFeePerVsize?: number, txid: string, acceleration?: boolean }[]): EffectiveFeeStats { const sortedTxs = transactions.map(tx => { return { txid: tx.txid, weight: tx.weight, rate: tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4)) }; }).sort((a, b) => a.rate - b.rate); @@ -877,9 +863,10 @@ export class Common { let medianFee = 0; let medianWeight = 0; - // calculate the "medianFee" as the average fee rate of the middle 10000 weight units of transactions - const leftBound = 1995000; - const rightBound = 2005000; + // calculate the "medianFee" as the average fee rate of the middle 0.25% weight units of transactions + const halfWidth = config.MEMPOOL.BLOCK_WEIGHT_UNITS / 800; + const leftBound = Math.floor((config.MEMPOOL.BLOCK_WEIGHT_UNITS / 2) - halfWidth); + const rightBound = Math.ceil((config.MEMPOOL.BLOCK_WEIGHT_UNITS / 2) + halfWidth); for (let i = 0; i < sortedTxs.length && weightCount < rightBound; i++) { const left = weightCount; const right = weightCount + sortedTxs[i].weight; @@ -946,6 +933,33 @@ export class Common { return this.validateTransactionHex(matches[1].toLowerCase()); } + static getTransactionsFromRequest(req: Request, limit: number = 25): string[] { + if (!Array.isArray(req.body) || req.body.some(hex => typeof hex !== 'string')) { + throw Object.assign(new Error('Invalid request body (should be an array of hexadecimal strings)'), { code: -1 }); + } + + if (limit && req.body.length > limit) { + throw Object.assign(new Error('Exceeded maximum of 25 transactions'), { code: -1 }); + } + + const txs = req.body; + + return txs.map(rawTx => { + // Support both upper and lower case hex + // Support both txHash= Form and direct API POST + const reg = /^((?:[a-fA-F0-9]{2})+)$/; + const matches = reg.exec(rawTx); + if (!matches || !matches[1]) { + throw Object.assign(new Error('Invalid hex string'), { code: -2 }); + } + + // Guaranteed to be a hex string of multiple of 2 + // Guaranteed to be lower case + // Guaranteed to pass validation (see function below) + return this.validateTransactionHex(matches[1].toLowerCase()); + }); + } + private static validateTransactionHex(txhex: string): string { // Do not mutate txhex diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts index 604c1b3c9..9da11328b 100644 --- a/backend/src/api/cpfp.ts +++ b/backend/src/api/cpfp.ts @@ -1,29 +1,174 @@ -import { CpfpInfo, MempoolTransactionExtended } from '../mempool.interfaces'; +import { Ancestor, CpfpCluster, CpfpInfo, CpfpSummary, MempoolTransactionExtended, TransactionExtended } from '../mempool.interfaces'; +import { GraphTx, convertToGraphTx, expandRelativesGraph, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from './mini-miner'; import memPool from './mempool'; +import { Acceleration } from './acceleration/acceleration'; const CPFP_UPDATE_INTERVAL = 60_000; // update CPFP info at most once per 60s per transaction -const MAX_GRAPH_SIZE = 50; // the maximum number of in-mempool relatives to consider +const MAX_CLUSTER_ITERATIONS = 100; -interface GraphTx extends MempoolTransactionExtended { - depends: string[]; - spentby: string[]; - ancestorMap: Map; - fees: { - base: number; - ancestor: number; +export function calculateFastBlockCpfp(height: number, transactions: MempoolTransactionExtended[], saveRelatives: boolean = false): CpfpSummary { + const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block + const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster + let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster + let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root + const txMap: { [txid: string]: TransactionExtended } = {}; + // initialize the txMap + for (const tx of transactions) { + txMap[tx.txid] = tx; + } + // reverse pass to identify CPFP clusters + for (let i = transactions.length - 1; i >= 0; i--) { + const tx = transactions[i]; + if (!ancestors[tx.txid]) { + let totalFee = 0; + let totalVSize = 0; + clusterTxs.forEach(tx => { + totalFee += tx?.fee || 0; + totalVSize += (tx.weight / 4); + }); + const effectiveFeePerVsize = totalFee / totalVSize; + let cluster: CpfpCluster; + if (clusterTxs.length > 1) { + cluster = { + root: clusterTxs[0].txid, + height, + txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }), + effectiveFeePerVsize, + }; + clusters.push(cluster); + } + clusterTxs.forEach(tx => { + txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize; + if (cluster) { + clusterMap[tx.txid] = cluster; + } + }); + // reset working vars + clusterTxs = []; + ancestors = {}; + } + clusterTxs.push(tx); + tx.vin.forEach(vin => { + ancestors[vin.txid] = true; + }); + } + // forward pass to enforce ancestor rate caps + for (const tx of transactions) { + let minAncestorRate = tx.effectiveFeePerVsize; + for (const vin of tx.vin) { + if (txMap[vin.txid]?.effectiveFeePerVsize) { + minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize); + } + } + // check rounded values to skip cases with almost identical fees + const roundedMinAncestorRate = Math.ceil(minAncestorRate); + const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize); + if (roundedMinAncestorRate < roundedEffectiveFeeRate) { + tx.effectiveFeePerVsize = minAncestorRate; + if (!clusterMap[tx.txid]) { + // add a single-tx cluster to record the dependent rate + const cluster = { + root: tx.txid, + height, + txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }], + effectiveFeePerVsize: minAncestorRate, + }; + clusterMap[tx.txid] = cluster; + clusters.push(cluster); + } else { + // update the existing cluster with the dependent rate + clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate; + } + } + } + if (saveRelatives) { + for (const cluster of clusters) { + cluster.txs.forEach((member, index) => { + txMap[member.txid].descendants = cluster.txs.slice(0, index).reverse(); + txMap[member.txid].ancestors = cluster.txs.slice(index + 1).reverse(); + txMap[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize; + }); + } + } + return { + transactions, + clusters, + version: 1, + }; +} + +export function calculateGoodBlockCpfp(height: number, transactions: MempoolTransactionExtended[], accelerations: Acceleration[]): CpfpSummary { + const txMap: { [txid: string]: MempoolTransactionExtended } = {}; + for (const tx of transactions) { + txMap[tx.txid] = tx; + } + const template = makeBlockTemplate(transactions, accelerations, 1, Infinity, Infinity); + const clusters = new Map(); + for (const tx of template) { + const cluster = tx.cluster || []; + const root = cluster.length ? cluster[cluster.length - 1] : null; + if (cluster.length > 1 && root && !clusters.has(root)) { + clusters.set(root, cluster); + } + txMap[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; + } + + const clusterArray: CpfpCluster[] = []; + + for (const cluster of clusters.values()) { + for (const txid of cluster) { + const mempoolTx = txMap[txid]; + if (mempoolTx) { + const ancestors: Ancestor[] = []; + const descendants: Ancestor[] = []; + let matched = false; + cluster.forEach(relativeTxid => { + if (relativeTxid === txid) { + matched = true; + } else { + const relative = { + txid: relativeTxid, + fee: txMap[relativeTxid].fee, + weight: (txMap[relativeTxid].adjustedVsize * 4) || txMap[relativeTxid].weight, + }; + if (matched) { + descendants.push(relative); + } else { + ancestors.push(relative); + } + } + }); + if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) { + mempoolTx.cpfpDirty = true; + } + Object.assign(mempoolTx, { ancestors, descendants, bestDescendant: null, cpfpChecked: true }); + } + } + const root = cluster[cluster.length - 1]; + clusterArray.push({ + root: root, + height, + txs: cluster.reverse().map(txid => ({ + txid, + fee: txMap[txid].fee, + weight: (txMap[txid].adjustedVsize * 4) || txMap[txid].weight, + })), + effectiveFeePerVsize: txMap[root].effectiveFeePerVsize, + }); + } + + return { + transactions: transactions.map(tx => txMap[tx.txid]), + clusters: clusterArray, + version: 2, }; - ancestorcount: number; - ancestorsize: number; - ancestorRate: number; - individualRate: number; - score: number; } /** * Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for * that transaction (and all others in the same cluster) */ -export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { +export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) { tx.cpfpDirty = false; return { @@ -32,30 +177,31 @@ export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid: descendants: tx.descendants || [], effectiveFeePerVsize: tx.effectiveFeePerVsize || tx.adjustedFeePerVsize || tx.feePerVsize, sigops: tx.sigops, + fee: tx.fee, adjustedVsize: tx.adjustedVsize, acceleration: tx.acceleration }; } const ancestorMap = new Map(); - const graphTx = mempoolToGraphTx(tx); + const graphTx = convertToGraphTx(tx, memPool.getSpendMap()); ancestorMap.set(tx.txid, graphTx); - const allRelatives = expandRelativesGraph(mempool, ancestorMap); + const allRelatives = expandRelativesGraph(mempool, ancestorMap, memPool.getSpendMap()); const relativesMap = initializeRelatives(allRelatives); const cluster = calculateCpfpCluster(tx.txid, relativesMap); let totalVsize = 0; let totalFee = 0; for (const tx of cluster.values()) { - totalVsize += tx.adjustedVsize; - totalFee += tx.fee; + totalVsize += tx.vsize; + totalFee += tx.fees.base; } const effectiveFeePerVsize = totalFee / totalVsize; for (const tx of cluster.values()) { mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize; - mempool[tx.txid].ancestors = Array.from(tx.ancestorMap.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee })); - mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestorMap.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee })); + mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); + mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); mempool[tx.txid].bestDescendant = null; mempool[tx.txid].cpfpChecked = true; mempool[tx.txid].cpfpDirty = true; @@ -70,88 +216,12 @@ export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid: descendants: tx.descendants || [], effectiveFeePerVsize: tx.effectiveFeePerVsize || tx.adjustedFeePerVsize || tx.feePerVsize, sigops: tx.sigops, + fee: tx.fee, adjustedVsize: tx.adjustedVsize, acceleration: tx.acceleration }; } -function mempoolToGraphTx(tx: MempoolTransactionExtended): GraphTx { - return { - ...tx, - depends: tx.vin.map(v => v.txid), - spentby: tx.vout.map((v, i) => memPool.getFromSpendMap(tx.txid, i)).map(tx => tx?.txid).filter(txid => txid != null) as string[], - ancestorMap: new Map(), - fees: { - base: tx.fee, - ancestor: tx.fee, - }, - ancestorcount: 1, - ancestorsize: tx.adjustedVsize, - ancestorRate: 0, - individualRate: 0, - score: 0, - }; -} - -/** - * Takes a map of transaction ancestors, and expands it into a full graph of up to MAX_GRAPH_SIZE in-mempool relatives - */ -function expandRelativesGraph(mempool: { [txid: string]: MempoolTransactionExtended }, ancestors: Map): Map { - const relatives: Map = new Map(); - const stack: GraphTx[] = Array.from(ancestors.values()); - while (stack.length > 0) { - if (relatives.size > MAX_GRAPH_SIZE) { - return relatives; - } - - const nextTx = stack.pop(); - if (!nextTx) { - continue; - } - relatives.set(nextTx.txid, nextTx); - - for (const relativeTxid of [...nextTx.depends, ...nextTx.spentby]) { - if (relatives.has(relativeTxid)) { - // already processed this tx - continue; - } - let mempoolTx = ancestors.get(relativeTxid); - if (!mempoolTx && mempool[relativeTxid]) { - mempoolTx = mempoolToGraphTx(mempool[relativeTxid]); - } - if (mempoolTx) { - stack.push(mempoolTx); - } - } - } - - return relatives; -} - - /** - * Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph - * by running setAncestors on each leaf, and caching intermediate results. - * then initializes ancestor data for each transaction - * - * @param all - */ - function initializeRelatives(mempoolTxs: Map): Map { - const visited: Map> = new Map(); - const leaves: GraphTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0); - for (const leaf of leaves) { - setAncestors(leaf, mempoolTxs, visited); - } - mempoolTxs.forEach(entry => { - entry.ancestorMap?.forEach(ancestor => { - entry.ancestorcount++; - entry.ancestorsize += ancestor.adjustedVsize; - entry.fees.ancestor += ancestor.fees.base; - }); - setAncestorScores(entry); - }); - return mempoolTxs; -} - /** * Given a root transaction and a list of in-mempool ancestors, * Calculate the CPFP cluster @@ -172,10 +242,10 @@ function calculateCpfpCluster(txid: string, graph: Map): Map(best?.ancestorMap?.entries() || []); - while (sortedRelatives.length && best && (best.txid !== tx.txid && !best.ancestorMap.has(tx.txid)) && maxIterations > 0) { + let bestCluster = new Map(best?.ancestors?.entries() || []); + while (sortedRelatives.length && best && (best.txid !== tx.txid && !best.ancestors.has(tx.txid)) && maxIterations > 0) { maxIterations--; if ((best && best.txid === tx.txid) || (bestCluster && bestCluster.has(tx.txid))) { break; @@ -190,7 +260,7 @@ function calculateCpfpCluster(txid: string, graph: Map): Map(best?.ancestorMap?.entries() || []); + bestCluster = new Map(best?.ancestors?.entries() || []); bestCluster.set(best?.txid, best); } } @@ -199,88 +269,4 @@ function calculateCpfpCluster(txid: string, graph: Map): Map, all: Map): void { - // remove - cluster.forEach(tx => { - all.delete(tx.txid); - }); - - // update survivors - all.forEach(tx => { - cluster.forEach(remove => { - if (tx.ancestorMap?.has(remove.txid)) { - // remove as dependency - tx.ancestorMap.delete(remove.txid); - tx.depends = tx.depends.filter(parent => parent !== remove.txid); - // update ancestor sizes and fees - tx.ancestorsize -= remove.adjustedVsize; - tx.fees.ancestor -= remove.fees.base; - } - }); - // recalculate fee rates - setAncestorScores(tx); - }); -} - -/** - * Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors - * for each transaction. - * - * @param tx - * @param all - */ -function setAncestors(tx: GraphTx, all: Map, visited: Map>, depth: number = 0): Map { - // sanity check for infinite recursion / too many ancestors (should never happen) - if (depth > MAX_GRAPH_SIZE) { - return tx.ancestorMap; - } - - // initialize the ancestor map for this tx - tx.ancestorMap = new Map(); - tx.depends.forEach(parentId => { - const parent = all.get(parentId); - if (parent) { - // add the parent - tx.ancestorMap?.set(parentId, parent); - // check for a cached copy of this parent's ancestors - let ancestors = visited.get(parent.txid); - if (!ancestors) { - // recursively fetch the parent's ancestors - ancestors = setAncestors(parent, all, visited, depth + 1); - } - // and add to this tx's map - ancestors.forEach((ancestor, ancestorId) => { - tx.ancestorMap?.set(ancestorId, ancestor); - }); - } - }); - visited.set(tx.txid, tx.ancestorMap); - - return tx.ancestorMap; -} - -/** - * Take a mempool transaction, and set the fee rates and ancestor score - * - * @param tx - */ -function setAncestorScores(tx: GraphTx): GraphTx { - tx.individualRate = (tx.fees.base * 100_000_000) / tx.adjustedVsize; - tx.ancestorRate = (tx.fees.ancestor * 100_000_000) / tx.ancestorsize; - tx.score = Math.min(tx.individualRate, tx.ancestorRate); - return tx; -} - -// Sort by descending score -function mempoolComparator(a: GraphTx, b: GraphTx): number { - return b.score - a.score; } \ No newline at end of file diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 81f2caa44..95f8c8707 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 76; + private static currentVersion = 82; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -653,9 +653,11 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `prices` ADD `TRY` float DEFAULT "-1"'); await this.$executeQuery('ALTER TABLE `prices` ADD `ZAR` float DEFAULT "-1"'); - await this.$executeQuery('TRUNCATE hashrates'); - await this.$executeQuery('TRUNCATE difficulty_adjustments'); - await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`); + if (isBitcoin === true) { + await this.$executeQuery('TRUNCATE hashrates'); + await this.$executeQuery('TRUNCATE difficulty_adjustments'); + await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`); + } await this.updateToSchemaVersion(75); } @@ -664,6 +666,45 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `blocks_audits` ADD prioritized_txs JSON DEFAULT "[]"'); await this.updateToSchemaVersion(76); } + + if (databaseSchemaVersion < 77 && config.MEMPOOL.NETWORK === 'mainnet') { + await this.$executeQuery('ALTER TABLE `accelerations` ADD requested datetime DEFAULT NULL'); + await this.updateToSchemaVersion(77); + } + + if (databaseSchemaVersion < 78) { + await this.$executeQuery('ALTER TABLE `prices` CHANGE `time` `time` datetime NOT NULL'); + await this.updateToSchemaVersion(78); + } + + if (databaseSchemaVersion < 79 && config.MEMPOOL.NETWORK === 'mainnet') { + // Clear bad data + await this.$executeQuery(`TRUNCATE accelerations`); + this.uniqueLog(logger.notice, `'accelerations' table has been truncated`); + await this.$executeQuery(` + UPDATE state + SET number = 0 + WHERE name = 'last_acceleration_block' + `); + await this.updateToSchemaVersion(79); + } + + if (databaseSchemaVersion < 80) { + await this.$executeQuery('ALTER TABLE `blocks` ADD coinbase_addresses JSON DEFAULT NULL'); + await this.updateToSchemaVersion(80); + } + + if (databaseSchemaVersion < 81 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `version` (`version`)'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD unseen_txs JSON DEFAULT "[]"'); + await this.updateToSchemaVersion(81); + } + + if (databaseSchemaVersion < 82 && isBitcoin === true && config.MEMPOOL.NETWORK === 'mainnet') { + await this.$fixBadV1AuditBlocks(); + await this.updateToSchemaVersion(82); + } } /** @@ -1278,6 +1319,28 @@ class DatabaseMigration { logger.warn(`Failed to migrate cpfp transaction data`); } } + + private async $fixBadV1AuditBlocks(): Promise { + const badBlocks = [ + '000000000000000000011ad49227fc8c9ba0ca96ad2ebce41a862f9a244478dc', + '000000000000000000010ac1f68b3080153f2826ffddc87ceffdd68ed97d6960', + '000000000000000000024cbdafeb2660ae8bd2947d166e7fe15d1689e86b2cf7', + '00000000000000000002e1dbfbf6ae057f331992a058b822644b368034f87286', + '0000000000000000000019973b2778f08ad6d21e083302ff0833d17066921ebb', + ]; + + for (const hash of badBlocks) { + try { + await this.$executeQuery(` + UPDATE blocks_audits + SET prioritized_txs = '[]' + WHERE hash = '${hash}' + `, true); + } catch (e) { + continue; + } + } + } } export default new DatabaseMigration(); diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index f28ab2a9d..391bf628e 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -54,9 +54,11 @@ class ChannelsRoutes { if (index < -1) { res.status(400).send('Invalid index'); + return; } if (['open', 'active', 'closed'].includes(status) === false) { res.status(400).send('Invalid status'); + return; } const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, 10, status); diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 22f9ca48a..22c854fcc 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -666,7 +666,9 @@ class NodesApi { node.last_update = null; } - const sockets = (node.addresses?.map(a => a.addr).join(',')) ?? ''; + const uniqueAddr = [...new Set(node.addresses?.map(a => a.addr))]; + const formattedSockets = (uniqueAddr.join(',')) ?? ''; + const query = `INSERT INTO nodes( public_key, first_seen, @@ -695,13 +697,13 @@ class NodesApi { node.alias, this.aliasToSearchText(node.alias), node.color, - sockets, + formattedSockets, JSON.stringify(node.features), node.last_update, node.alias, this.aliasToSearchText(node.alias), node.color, - sockets, + formattedSockets, JSON.stringify(node.features), ]); } catch (e) { @@ -713,7 +715,9 @@ class NodesApi { * Update node sockets */ public async $updateNodeSockets(publicKey: string, sockets: {network: string; addr: string}[]): Promise { - const formattedSockets = (sockets.map(a => a.addr).join(',')) ?? ''; + const uniqueAddr = [...new Set(sockets.map(a => a.addr))]; + + const formattedSockets = (uniqueAddr.join(',')) ?? ''; try { await DB.query(`UPDATE nodes SET sockets = ? WHERE public_key = ?`, [formattedSockets, publicKey]); } catch (e) { diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 3636abe2b..9d6373845 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -48,6 +48,14 @@ class NodesRoutes { '032850492ee61a5f7006a2fda6925e4b4ec3782f2b6de2ff0e439ef5a38c3b2470', '022c80bace98831c44c32fb69755f2b353434e0ee9e7fbda29507f7ef8abea1421', '02c3559c833e6f99f9ca05fe503e0b4e7524dea9121344edfd3e811101e0c28680', + '02b36a324fa2dd3af2a63ac65f241907882829bed5002b4e14171d25c219e0d470', + '0231b6e8f21f9f6c057f6bf8a812f79e396ee16a66ece91939a1576ce9fb9e87a5', + '034b6aac206bffcbd651b7ead1ab8a0991c945dfafe19ff27dcdeadc6843ebd15c', + '039c065f7e344acd969ebdd4a94550915b6f24e8782ae2be540bb96c8a4fcfb86b', + '03d9f9f4803fc75920f14dd13d83fbecc53229a65d4ee4cd2d86fdf211f7337576', + '0357fe48c4dece744f70865eda66e396aab5d05e09e1145cd3b7da83f11446d4cf', + '02bca4d642eda631f2c8659758e2a2868e518b93503f2bfcd767749c6530a10679', + '03f32c99c0bb9f62dae53671d1d300565773455248f34134cc02779b881561174e', '032c7c7819276c4f706a04df1a0f1e10a5495994a7be4c1d3d28ca766e5a2b957b', '025a7e38c2834dd843591a4d23d5f09cdeb77ddca85f673c2d944a14220ff14cf7', '0395e2731a1673ef21d7a16a727c4fc4d4c35a861c428ce2c819c53d2b81c8bd55', @@ -60,6 +68,14 @@ class NodesRoutes { '039c14fdec2d958e3d14cebf657451bbd9e039196615785e82c917f274e3fb2205', '033589bbcb233ffc416cefd5437c7f37e9d7cb7942d405e39e72c4c846d9b37f18', '029293110441c6e2eacb57e1255bf6ef05c41a6a676fe474922d33c19f98a7d584', + '038eb09bed4532ff36d12acc1279f55cbe8d95212d19f809e057bb50de00051fba', + '027b7c0278366a0268e8bd0072b14539f6cb455a7bd588ae22d888bed541f65311', + '02f4dd78f6eda8838029b2cdbaaea6e875e2fa373cd348ee41a7c1bb177d3fca66', + '036b3fb692da214a3edaac5b67903b958f5ccd8712e09aa61b67ea7acfd94b40c2', + '023bc8915d308e0b65f8de6867f95960141372436fce3edad5cec3f364d6ac948f', + '0341690503ef21d0e203dddd9e62646380d0dfc32c499e055e7f698b9064d1c736', + '0355d573805c018a37a5b2288378d70e9b5b438f7394abd6f467cb9b47c90eeb93', + '0361aa68deb561a8b47b41165848edcccb98a1b56a5ea922d9d5b30a09bb7282ea', '0235ad0b56ed8c42c4354444c24e971c05e769ec0b5fb0ccea42880095dc02ea2c', '029700819a37afea630f80e6cc461f3fd3c4ace2598a21cfbbe64d1c78d0ee69a5', '02c2d8b2dbf87c7894af2f1d321290e2fe6db5446cd35323987cee98f06e2e0075', @@ -76,6 +92,14 @@ class NodesRoutes { '0243348cb3741cfe2d8485fa8375c29c7bc7cbb67577c363cb6987a5e5fd0052cc', '02cb73e631af44bee600d80f8488a9194c9dc5c7590e575c421a070d1be05bc8e9', '0306f55ee631aa1e2cd4d9b2bfcbc14404faec5c541cef8b2e6f779061029d09c4', + '030bbbd8495561a894e301fe6ba5b22f8941fc661cc0e673e0206158231d8ac130', + '03ee1f08e516ed083475f39c6cae4fa1eec686d004d2f105218269e27d7f2da5a4', + '028c378b998f476ed22d6815c170dd2a3388a43fdf791a7cff70b9997349b8447a', + '036f19f044d19cb1b04f14d91b6e7e5443ce337217a8c14d43861f3e86dd07bd7f', + '03058d61869e8b88436493648b2e3e530627edf5a0b253c285cd565c1477a5c237', + '0279dfedc87b47a941f1797f2c422c03aa3108914ea6b519d76537d60860535a9a', + '0353486b8016761e58ec8aee7305ee58d5dc66b55ef5bd8cbaf49508f66d52d62e', + '03df5db8eccfabcae47ff15553cfdecb2d3f56979f43a0c3578f28d056b5e35104', '03ddab321b760433cbf561b615ef62ac7d318630c5f51d523aaf5395b90b751956', '033d92c7bfd213ef1b34c90e985fb5dc77f9ec2409d391492484e57a44c4aca1de', '02ad010dda54253c1eb9efe38b0760657a3b43ecad62198c359c051c9d99d45781', @@ -88,6 +112,14 @@ class NodesRoutes { '038310e3a786340f2bd7770704c7ccfe560fd163d9a1c99d67894597419d12cbf7', '03e5e9d879b72c7d67ecd483bae023bd33e695bb32b981a4021260f7b9d62bc761', '028d16e1a0ace4c0c0a421536d8d32ce484dfe6e2f726b7b0e7c30f12a195f8cc7', + '0326cf9a4ca67a5b9cdffae57293dbd6f7c5113b93010dc6f6fe4af3afde1a1739', + '034867e16f62cebb8c2c2c22b91117c173bbece9c8a1e5bd001374a3699551cd8f', + '038dfb1f1b637a8c27e342ffc6f9feca20e0b47be3244e09ae78df4998e2ae83b9', + '03cb1cea3394d973355c11bc61c2f689f9d3e1c3db60d205f27770f5ad83200f77', + '03535447b592cbdb153189b3e06a455452b1011380cb3e6511a31090c15d8efc9f', + '028e90e9984d262ebfa3c23fb3f335a2ae061a0bdedee03f45f72b438d9e7d2ce3', + '03ee0176289dc4a6111fa5ef22eed5273758c420fbe58cc1d2d76def75dd7e640c', + '0370b2cd9f0eaf436d5c25c93fb39210d8cc06b31f688fc2f54418aabe394aed79', '02ff690d06c187ab994bf83c5a2114fe5bf50112c2c817af0f788f736be9fa2070', '02a9f570c51a2526a5ee85802e88f9281bed771eb66a0c8a7d898430dd5d0eae45', '038c3de773255d3bd7a50e31e58d423baac5c90826a74d75e64b74c95475de1097', @@ -104,6 +136,14 @@ class NodesRoutes { '03229ab4b7f692753e094b93df90530150680f86b535b5183b0cffd75b3df583fc', '03a696eb7acde991c1be97a58a9daef416659539ae462b897f5e9ae361f990228e', '0248bf26cf3a63ab8870f34dc0ec9e6c8c6288cdba96ba3f026f34ec0f13ac4055', + '021b28ecdd782fd909705d6be354db268977b1a2ac5a5275186fc19e08bb8fca93', + '031bec1fbd8eb7fe94d2bda108c9c3cc8c22ecfc1c3a5c11d36f5881b01b4a81a6', + '03879c4f827a3188574d5757e002f574265a966d70aea942169785b31369b067d5', + '0228d4b5a4fd73a03967b76f8b8cb37b9d0b6e7039126a9397bb732c15bed78e9b', + '03f58dbb629f4427f5a1dbc02e6a7ec79345fdf13a0e4163d4f3b7aea2539cf095', + '021cdcb8123aa670cdfc9f43909dbb297363c093883409e9e7fc82e7267f7c72bd', + '02f2aa2c2b7b432a70dc4d0b04afa19d48715ed3b90594d49c1c8744f2e9ebb030', + '03709a02fb3ab4857689a8ea0bd489a6ab6f56f8a397be578bc6d5ad22efbe3756', '03fbc17549ec667bccf397ababbcb4cdc0e3394345e4773079ab2774612ec9be61', '03da9a8623241ccf95f19cd645c6cecd4019ac91570e976eb0a128bebbc4d8a437', '03ca5340cf85cb2e7cf076e489f785410838de174e40be62723e8a60972ad75144', @@ -116,6 +156,14 @@ class NodesRoutes { '02b6b1640fe029e304c216951af9fbefdb23b0bdc9baaf327540d31b6107841fdf', '03694289827203a5b3156d753071ddd5bf92e371f5a462943f9555eef6d2d6606c', '0283d850db7c3e8ea7cc9c4abc7afaab12bbdf72b677dcba1d608350d2537d7d43', + '03b4dda7878d3b7b71ecd6d4738322c7f9a9c1fb583374d2724f4ccc4947f37570', + '0279a35f05b5acf159429549e56fd426685c4fec191431c58738968bbc77a39f25', + '03cb102d796ddcf08610cd03fae8b7a1df69ff48e9e8a152af315f9edf71762eb8', + '036b89526f4d5ac4c317f4fd23cb9f8e4ad844498bc7950a41114d060101d995d4', + '0313eade145959d7036db009fd5b0bf1947a739c7c3c790b491ec9161b94e6ad1e', + '02b670ca4c4bb2c5ea89c3b691da98a194cfc48fcd5c072df02a20290bddd60610', + '02a9196d5e08598211397a83cf013a5962b84bd61198abfdd204dff987e54f7a0d', + '036d015cd2f486fb38348182980b7e596e6c9733873102ea126fed7b4152be03b8', '02521287789f851268a39c9eccc9d6180d2c614315b583c9e6ae0addbd6d79df06', '0258c2a7b7f8af2585b4411b1ec945f70988f30412bb1df179de941f14d0b1bc3e', '03c3389ff1a896f84d921ed01a19fc99c6724ce8dc4b960cd3b7b2362b62cd60d7', diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index bded93846..5d9dcf8f4 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,11 +1,13 @@ import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt'; import logger from '../logger'; -import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, TransactionClassified, TransactionCompressed, MempoolDeltaChange, GbtCandidates } from '../mempool.interfaces'; +import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, TransactionClassified, TransactionCompressed, MempoolDeltaChange, GbtCandidates, PoolTag } from '../mempool.interfaces'; import { Common, OnlineFeeStatsCalculator } from './common'; import config from '../config'; import { Worker } from 'worker_threads'; import path from 'path'; import mempool from './mempool'; +import { Acceleration } from './services/acceleration'; +import PoolsRepository from '../repositories/PoolsRepository'; const MAX_UINT32 = Math.pow(2, 32) - 1; @@ -14,12 +16,14 @@ class MempoolBlocks { private mempoolBlockDeltas: MempoolBlockDelta[] = []; private txSelectionWorker: Worker | null = null; private rustInitialized: boolean = false; - private rustGbtGenerator: GbtGenerator = new GbtGenerator(); + private rustGbtGenerator: GbtGenerator = new GbtGenerator(config.MEMPOOL.BLOCK_WEIGHT_UNITS, config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT); private nextUid: number = 1; private uidMap: Map = new Map(); // map short numerical uids to full txids private txidMap: Map = new Map(); // map full txids back to short numerical uids + private pools: { [id: number]: PoolTag } = {}; + public getMempoolBlocks(): MempoolBlock[] { return this.mempoolBlocks.map((block) => { return { @@ -41,6 +45,18 @@ class MempoolBlocks { return this.mempoolBlockDeltas; } + public async updatePools$(): Promise { + if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { + this.pools = {}; + return; + } + const allPools = await PoolsRepository.$getPools(); + this.pools = {}; + for (const pool of allPools) { + this.pools[pool.uniqueId] = pool; + } + } + private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] { const mempoolBlockDeltas: MempoolBlockDelta[] = []; for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { @@ -214,7 +230,7 @@ class MempoolBlocks { private resetRustGbt(): void { this.rustInitialized = false; - this.rustGbtGenerator = new GbtGenerator(); + this.rustGbtGenerator = new GbtGenerator(config.MEMPOOL.BLOCK_WEIGHT_UNITS, config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT); } public async $rustMakeBlockTemplates(txids: string[], newMempool: { [txid: string]: MempoolTransactionExtended }, candidates: GbtCandidates | undefined, saveResults: boolean = false, useAccelerations: boolean = false, accelerationPool?: number): Promise { @@ -246,7 +262,7 @@ class MempoolBlocks { }); // run the block construction algorithm in a separate thread, and wait for a result - const rustGbt = saveResults ? this.rustGbtGenerator : new GbtGenerator(); + const rustGbt = saveResults ? this.rustGbtGenerator : new GbtGenerator(config.MEMPOOL.BLOCK_WEIGHT_UNITS, config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT); try { const { blocks, blockWeights, rates, clusters, overflow } = this.convertNapiResultTxids( await rustGbt.make(transactions as RustThreadTransaction[], convertedAccelerations as RustThreadAcceleration[], this.nextUid), @@ -333,10 +349,13 @@ class MempoolBlocks { } } - private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] { + private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations: { [txid: string]: Acceleration }, accelerationPool, saveResults): MempoolBlockWithTransactions[] { for (const txid of Object.keys(candidates?.txs ?? mempool)) { if (txid in mempool) { mempool[txid].cpfpDirty = false; + mempool[txid].ancestors = []; + mempool[txid].descendants = []; + mempool[txid].bestDescendant = null; } } for (const [txid, rate] of rates) { @@ -396,7 +415,7 @@ class MempoolBlocks { } } - const isAccelerated : { [txid: string]: boolean } = {}; + const isAcceleratedBy : { [txid: string]: number[] | false } = {}; const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; // update this thread's mempool with the results @@ -427,17 +446,23 @@ class MempoolBlocks { }; const acceleration = accelerations[txid]; - if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { + if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { if (!mempoolTx.acceleration) { mempoolTx.cpfpDirty = true; } mempoolTx.acceleration = true; + mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools; + mempoolTx.acceleratedAt = acceleration?.added; + mempoolTx.feeDelta = acceleration?.feeDelta; for (const ancestor of mempoolTx.ancestors || []) { if (!mempool[ancestor.txid].acceleration) { mempool[ancestor.txid].cpfpDirty = true; } mempool[ancestor.txid].acceleration = true; - isAccelerated[ancestor.txid] = true; + mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy; + mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt; + mempool[ancestor.txid].feeDelta = mempoolTx.feeDelta; + isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy; } } else { if (mempoolTx.acceleration) { @@ -475,7 +500,7 @@ class MempoolBlocks { const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); this.mempoolBlocks = mempoolBlocks; this.mempoolBlockDeltas = deltas; - + this.updateAccelerationPositions(mempool, accelerations, mempoolBlocks); } return mempoolBlocks; @@ -622,6 +647,124 @@ class MempoolBlocks { tx.acc ? 1 : 0, ]; } + + // estimates and saves positions of accelerations in mining partner mempools + private updateAccelerationPositions(mempoolCache: { [txid: string]: MempoolTransactionExtended }, accelerations: { [txid: string]: Acceleration }, mempoolBlocks: MempoolBlockWithTransactions[]): void { + const accelerationPositions: { [txid: string]: { poolId: number, pool: string, block: number, vsize: number }[] } = {}; + // keep track of simulated mempool blocks for each active pool + const pools: { + [pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean }; + } = {}; + // prepare a list of accelerations in ascending order (we'll pop items off the end of the list) + const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).map(acc => { + let vsize = mempoolCache[acc.txid].vsize; + for (const ancestor of mempoolCache[acc.txid].ancestors || []) { + vsize += (ancestor.weight / 4); + } + return { + acceleration: acc, + rate: mempoolCache[acc.txid].effectiveFeePerVsize, + vsize + }; + }).sort((a, b) => a.rate - b.rate); + // initialize the pool tracker + for (const { acceleration } of accQueue) { + accelerationPositions[acceleration.txid] = []; + for (const pool of acceleration.pools) { + if (!pools[pool]) { + pools[pool] = { + name: this.pools[pool]?.name || 'unknown', + block: 0, + vsize: 0, + accelerations: [], + complete: false, + }; + } + pools[pool].accelerations.push(acceleration.txid); + } + for (const ancestor of mempoolCache[acceleration.txid].ancestors || []) { + accelerationPositions[ancestor.txid] = []; + } + } + + for (const pool of Object.keys(pools)) { + // if any pools accepted *every* acceleration, we can just use the GBT result positions directly + if (pools[pool].accelerations.length === Object.keys(accelerations).length) { + pools[pool].complete = true; + } + } + + let block = 0; + let index = 0; + let next = accQueue.pop(); + // build simulated blocks for each pool by taking the best option from + // either the mempool or the list of accelerations. + while (next && block < mempoolBlocks.length) { + while (next && index < mempoolBlocks[block].transactions.length) { + const nextTx = mempoolBlocks[block].transactions[index]; + if (next.rate >= (nextTx.rate || (nextTx.fee / nextTx.vsize))) { + for (const pool of next.acceleration.pools) { + if (pools[pool].vsize + next.vsize <= 999_000) { + pools[pool].vsize += next.vsize; + } else { + pools[pool].block++; + pools[pool].vsize = next.vsize; + } + // insert the acceleration into matching pool's blocks + if (pools[pool].complete && mempoolCache[next.acceleration.txid]?.position !== undefined) { + accelerationPositions[next.acceleration.txid].push({ + ...mempoolCache[next.acceleration.txid].position as { block: number, vsize: number }, + poolId: pool, + pool: pools[pool].name + }); + } else { + accelerationPositions[next.acceleration.txid].push({ + poolId: pool, + pool: pools[pool].name, + block: pools[pool].block, + vsize: pools[pool].vsize - (next.vsize / 2), + }); + } + // and any accelerated ancestors + for (const ancestor of mempoolCache[next.acceleration.txid].ancestors || []) { + if (pools[pool].complete && mempoolCache[ancestor.txid]?.position !== undefined) { + accelerationPositions[ancestor.txid].push({ + ...mempoolCache[ancestor.txid].position as { block: number, vsize: number }, + poolId: pool, + pool: pools[pool].name, + }); + } else { + accelerationPositions[ancestor.txid].push({ + poolId: pool, + pool: pools[pool].name, + block: pools[pool].block, + vsize: pools[pool].vsize - (next.vsize / 2), + }); + } + } + } + next = accQueue.pop(); + } else { + // skip accelerated transactions and their CPFP ancestors + if (accelerationPositions[nextTx.txid] == null) { + // insert into all pools' blocks + for (const pool of Object.keys(pools)) { + if (pools[pool].vsize + nextTx.vsize <= 999_000) { + pools[pool].vsize += nextTx.vsize; + } else { + pools[pool].block++; + pools[pool].vsize = nextTx.vsize; + } + } + } + index++; + } + } + block++; + index = 0; + } + mempool.setAccelerationPositions(accelerationPositions); + } } export default new MempoolBlocks(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 176bedddb..1f55179fb 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -27,6 +27,7 @@ class Mempool { deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[], candidates?: GbtCandidates) => Promise) | undefined; private accelerations: { [txId: string]: Acceleration } = {}; + private accelerationPositions: { [txid: string]: { poolId: number, pool: string, block: number, vsize: number }[] } = {}; private txPerSecondArray: number[] = []; private txPerSecond: number = 0; @@ -395,15 +396,15 @@ class Mempool { } public $updateAccelerations(newAccelerations: Acceleration[]): string[] { - if (!config.MEMPOOL_SERVICES.ACCELERATIONS) { - return []; - } - try { const changed: string[] = []; const newAccelerationMap: { [txid: string]: Acceleration } = {}; for (const acceleration of newAccelerations) { + // skip transactions we don't know about + if (!this.mempoolCache[acceleration.txid]) { + continue; + } newAccelerationMap[acceleration.txid] = acceleration; if (this.accelerations[acceleration.txid] == null) { // new acceleration @@ -510,6 +511,14 @@ class Mempool { } } + setAccelerationPositions(positions: { [txid: string]: { poolId: number, pool: string, block: number, vsize: number }[] }): void { + this.accelerationPositions = positions; + } + + getAccelerationPositions(txid: string): { [pool: number]: { poolId: number, pool: string, block: number, vsize: number } } | undefined { + return this.accelerationPositions[txid]; + } + private startTimer() { const state: any = { start: Date.now(), diff --git a/backend/src/api/mini-miner.ts b/backend/src/api/mini-miner.ts new file mode 100644 index 000000000..0bef1a819 --- /dev/null +++ b/backend/src/api/mini-miner.ts @@ -0,0 +1,515 @@ +import { Acceleration } from './acceleration/acceleration'; +import { MempoolTransactionExtended } from '../mempool.interfaces'; +import logger from '../logger'; + +const BLOCK_WEIGHT_UNITS = 4_000_000; +const BLOCK_SIGOPS = 80_000; +const MAX_RELATIVE_GRAPH_SIZE = 100; + +export interface GraphTx { + txid: string; + vsize: number; + weight: number; + depends: string[]; + spentby: string[]; + + ancestorcount: number; + ancestorsize: number; + fees: { // in sats + base: number; + ancestor: number; + }; + + ancestors: Map, + ancestorRate: number; + individualRate: number; + score: number; +} + +interface TemplateTransaction { + txid: string; + order: number; + weight: number; + adjustedVsize: number; // sigop-adjusted vsize, rounded up to the nearest integer + sigops: number; + fee: number; + feeDelta: number; + ancestors: string[]; + cluster: string[]; + effectiveFeePerVsize: number; +} + +interface MinerTransaction extends TemplateTransaction { + inputs: string[]; + feePerVsize: number; + relativesSet: boolean; + ancestorMap: Map; + children: Set; + ancestorFee: number; + ancestorVsize: number; + ancestorSigops: number; + score: number; + used: boolean; + modified: boolean; + dependencyRate: number; +} + +/** + * Takes a raw transaction, and builds a graph of same-block relatives, + * and returns as a GraphTx + * + * @param tx + */ +export function getSameBlockRelatives(tx: MempoolTransactionExtended, transactions: MempoolTransactionExtended[]): Map { + const blockTxs = new Map(); // map of txs in this block + const spendMap = new Map(); // map of outpoints to spending txids + for (const tx of transactions) { + blockTxs.set(tx.txid, tx); + for (const vin of tx.vin) { + spendMap.set(`${vin.txid}:${vin.vout}`, tx.txid); + } + } + + const relatives: Map = new Map(); + const stack: string[] = [tx.txid]; + + // build set of same-block ancestors + while (stack.length > 0) { + const nextTxid = stack.pop(); + const nextTx = nextTxid ? blockTxs.get(nextTxid) : null; + if (!nextTx || relatives.has(nextTx.txid)) { + continue; + } + + const mempoolTx = convertToGraphTx(nextTx, spendMap); + + for (const txid of [...mempoolTx.depends, ...mempoolTx.spentby]) { + if (txid) { + stack.push(txid); + } + } + + relatives.set(mempoolTx.txid, mempoolTx); + } + + return relatives; +} + +/** + * Takes a raw transaction and converts it to GraphTx format + * fee and ancestor data is initialized with dummy/null values + * + * @param tx + */ +export function convertToGraphTx(tx: MempoolTransactionExtended, spendMap?: Map): GraphTx { + return { + txid: tx.txid, + vsize: Math.max(tx.sigops * 5, Math.ceil(tx.weight / 4)), + weight: tx.weight, + fees: { + base: tx.fee || 0, + ancestor: tx.fee || 0, + }, + depends: (tx.vin.map(vin => vin.txid).filter(depend => depend) as string[]), + spentby: spendMap ? (tx.vout.map((vout, index) => { const spend = spendMap.get(`${tx.txid}:${index}`); return (spend?.['txid'] || spend); }).filter(spent => spent) as string[]) : [], + + ancestorcount: 1, + ancestorsize: Math.max(tx.sigops * 5, Math.ceil(tx.weight / 4)), + ancestors: new Map(), + ancestorRate: 0, + individualRate: 0, + score: 0, + }; +} + +/** + * Takes a map of transaction ancestors, and expands it into a full graph of up to MAX_GRAPH_SIZE in-mempool relatives + */ +export function expandRelativesGraph(mempool: { [txid: string]: MempoolTransactionExtended }, ancestors: Map, spendMap: Map): Map { + const relatives: Map = new Map(); + const stack: GraphTx[] = Array.from(ancestors.values()); + while (stack.length > 0) { + if (relatives.size > MAX_RELATIVE_GRAPH_SIZE) { + return relatives; + } + + const nextTx = stack.pop(); + if (!nextTx) { + continue; + } + relatives.set(nextTx.txid, nextTx); + + for (const relativeTxid of [...nextTx.depends, ...nextTx.spentby]) { + if (relatives.has(relativeTxid)) { + // already processed this tx + continue; + } + let ancestorTx = ancestors.get(relativeTxid); + if (!ancestorTx && relativeTxid in mempool) { + const mempoolTx = mempool[relativeTxid]; + ancestorTx = convertToGraphTx(mempoolTx, spendMap); + } + if (ancestorTx) { + stack.push(ancestorTx); + } + } + } + + return relatives; +} + +/** + * Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors + * for each transaction. + * + * @param tx + * @param all + */ +function setAncestors(tx: GraphTx, all: Map, visited: Map>, depth: number = 0): Map { + // sanity check for infinite recursion / too many ancestors (should never happen) + if (depth > MAX_RELATIVE_GRAPH_SIZE) { + logger.warn('cpfp dependency calculation failed: setAncestors reached depth of 100, unable to proceed'); + return tx.ancestors; + } + + // initialize the ancestor map for this tx + tx.ancestors = new Map(); + tx.depends.forEach(parentId => { + const parent = all.get(parentId); + if (parent) { + // add the parent + tx.ancestors?.set(parentId, parent); + // check for a cached copy of this parent's ancestors + let ancestors = visited.get(parent.txid); + if (!ancestors) { + // recursively fetch the parent's ancestors + ancestors = setAncestors(parent, all, visited, depth + 1); + } + // and add to this tx's map + ancestors.forEach((ancestor, ancestorId) => { + tx.ancestors?.set(ancestorId, ancestor); + }); + } + }); + visited.set(tx.txid, tx.ancestors); + + return tx.ancestors; +} + +/** + * Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph + * by running setAncestors on each leaf, and caching intermediate results. + * then initializes ancestor data for each transaction + * + * @param all + */ +export function initializeRelatives(mempoolTxs: Map): Map { + const visited: Map> = new Map(); + const leaves: GraphTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0); + for (const leaf of leaves) { + setAncestors(leaf, mempoolTxs, visited); + } + mempoolTxs.forEach(entry => { + entry.ancestors?.forEach(ancestor => { + entry.ancestorcount++; + entry.ancestorsize += ancestor.vsize; + entry.fees.ancestor += ancestor.fees.base; + }); + setAncestorScores(entry); + }); + return mempoolTxs; +} + +/** + * Remove a cluster of transactions from an in-mempool dependency graph + * and update the survivors' scores and ancestors + * + * @param cluster + * @param ancestors + */ +export function removeAncestors(cluster: Map, all: Map): void { + // remove + cluster.forEach(tx => { + all.delete(tx.txid); + }); + + // update survivors + all.forEach(tx => { + cluster.forEach(remove => { + if (tx.ancestors?.has(remove.txid)) { + // remove as dependency + tx.ancestors.delete(remove.txid); + tx.depends = tx.depends.filter(parent => parent !== remove.txid); + // update ancestor sizes and fees + tx.ancestorsize -= remove.vsize; + tx.fees.ancestor -= remove.fees.base; + } + }); + // recalculate fee rates + setAncestorScores(tx); + }); +} + +/** + * Take a mempool transaction, and set the fee rates and ancestor score + * + * @param tx + */ +export function setAncestorScores(tx: GraphTx): void { + tx.individualRate = tx.fees.base / tx.vsize; + tx.ancestorRate = tx.fees.ancestor / tx.ancestorsize; + tx.score = Math.min(tx.individualRate, tx.ancestorRate); +} + +// Sort by descending score +export function mempoolComparator(a: GraphTx, b: GraphTx): number { + return b.score - a.score; +} + +/* +* Build a block using an approximation of the transaction selection algorithm from Bitcoin Core +* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp) +*/ +export function makeBlockTemplate(candidates: MempoolTransactionExtended[], accelerations: Acceleration[], maxBlocks: number = 8, weightLimit: number = BLOCK_WEIGHT_UNITS, sigopLimit: number = BLOCK_SIGOPS): TemplateTransaction[] { + const auditPool: Map = new Map(); + const mempoolArray: MinerTransaction[] = []; + + candidates.forEach(tx => { + // initializing everything up front helps V8 optimize property access later + const adjustedVsize = Math.ceil(Math.max(tx.weight / 4, 5 * (tx.sigops || 0))); + const feePerVsize = (tx.fee / adjustedVsize); + auditPool.set(tx.txid, { + txid: tx.txid, + order: txidToOrdering(tx.txid), + fee: tx.fee, + feeDelta: 0, + weight: tx.weight, + adjustedVsize, + feePerVsize: feePerVsize, + effectiveFeePerVsize: feePerVsize, + dependencyRate: feePerVsize, + sigops: tx.sigops || 0, + inputs: (tx.vin?.map(vin => vin.txid) || []) as string[], + relativesSet: false, + ancestors: [], + cluster: [], + ancestorMap: new Map(), + children: new Set(), + ancestorFee: 0, + ancestorVsize: 0, + ancestorSigops: 0, + score: 0, + used: false, + modified: false, + }); + mempoolArray.push(auditPool.get(tx.txid) as MinerTransaction); + }); + + // set accelerated effective fee + for (const acceleration of accelerations) { + const tx = auditPool.get(acceleration.txid); + if (tx) { + tx.feeDelta = acceleration.max_bid; + tx.feePerVsize = ((tx.fee + tx.feeDelta) / tx.adjustedVsize); + tx.effectiveFeePerVsize = tx.feePerVsize; + tx.dependencyRate = tx.feePerVsize; + } + } + + // Build relatives graph & calculate ancestor scores + for (const tx of mempoolArray) { + if (!tx.relativesSet) { + setRelatives(tx, auditPool); + } + } + + // Sort by descending ancestor score + mempoolArray.sort(priorityComparator); + + // Build blocks by greedily choosing the highest feerate package + // (i.e. the package rooted in the transaction with the best ancestor score) + const blocks: number[][] = []; + let blockWeight = 0; + let blockSigops = 0; + const transactions: MinerTransaction[] = []; + let modified: MinerTransaction[] = []; + const overflow: MinerTransaction[] = []; + let failures = 0; + while (mempoolArray.length || modified.length) { + // skip invalid transactions + while (mempoolArray[0]?.used || mempoolArray[0]?.modified) { + mempoolArray.shift(); + } + + // Select best next package + let nextTx; + const nextPoolTx = mempoolArray[0]; + const nextModifiedTx = modified[0]; + if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) { + nextTx = nextPoolTx; + mempoolArray.shift(); + } else { + modified.shift(); + if (nextModifiedTx) { + nextTx = nextModifiedTx; + } + } + + if (nextTx && !nextTx?.used) { + // Check if the package fits into this block + if (blocks.length >= (maxBlocks - 1) || ((blockWeight + (4 * nextTx.ancestorVsize) < weightLimit) && (blockSigops + nextTx.ancestorSigops <= sigopLimit))) { + const ancestors: MinerTransaction[] = Array.from(nextTx.ancestorMap.values()); + // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) + const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; + const clusterTxids = sortedTxSet.map(tx => tx.txid); + const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / nextTx.ancestorVsize); + const used: MinerTransaction[] = []; + while (sortedTxSet.length) { + const ancestor = sortedTxSet.pop(); + if (!ancestor) { + continue; + } + ancestor.used = true; + ancestor.usedBy = nextTx.txid; + // update this tx with effective fee rate & relatives data + if (ancestor.effectiveFeePerVsize !== effectiveFeeRate) { + ancestor.effectiveFeePerVsize = effectiveFeeRate; + } + ancestor.cluster = clusterTxids; + transactions.push(ancestor); + blockWeight += ancestor.weight; + blockSigops += ancestor.sigops; + used.push(ancestor); + } + + // remove these as valid package ancestors for any descendants remaining in the mempool + if (used.length) { + used.forEach(tx => { + modified = updateDescendants(tx, auditPool, modified, effectiveFeeRate); + }); + } + + failures = 0; + } else { + // hold this package in an overflow list while we check for smaller options + overflow.push(nextTx); + failures++; + } + } + + // this block is full + const exceededPackageTries = failures > 1000 && blockWeight > (weightLimit - 4000); + const queueEmpty = !mempoolArray.length && !modified.length; + + if (exceededPackageTries || queueEmpty) { + break; + } + } + + for (const tx of transactions) { + tx.ancestors = Object.values(tx.ancestorMap); + } + + return transactions; +} + +// traverse in-mempool ancestors +// recursion unavoidable, but should be limited to depth < 25 by mempool policy +function setRelatives( + tx: MinerTransaction, + mempool: Map, +): void { + for (const parent of tx.inputs) { + const parentTx = mempool.get(parent); + if (parentTx && !tx.ancestorMap?.has(parent)) { + tx.ancestorMap.set(parent, parentTx); + parentTx.children.add(tx); + // visit each node only once + if (!parentTx.relativesSet) { + setRelatives(parentTx, mempool); + } + parentTx.ancestorMap.forEach((ancestor) => { + tx.ancestorMap.set(ancestor.txid, ancestor); + }); + } + }; + tx.ancestorFee = (tx.fee + tx.feeDelta); + tx.ancestorVsize = tx.adjustedVsize || 0; + tx.ancestorSigops = tx.sigops || 0; + tx.ancestorMap.forEach((ancestor) => { + tx.ancestorFee += (ancestor.fee + ancestor.feeDelta); + tx.ancestorVsize += ancestor.adjustedVsize; + tx.ancestorSigops += ancestor.sigops; + }); + tx.score = tx.ancestorFee / tx.ancestorVsize; + tx.relativesSet = true; +} + +// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score +// avoids recursion to limit call stack depth +function updateDescendants( + rootTx: MinerTransaction, + mempool: Map, + modified: MinerTransaction[], + clusterRate: number, +): MinerTransaction[] { + const descendantSet: Set = new Set(); + // stack of nodes left to visit + const descendants: MinerTransaction[] = []; + let descendantTx: MinerTransaction | undefined; + rootTx.children.forEach(childTx => { + if (!descendantSet.has(childTx)) { + descendants.push(childTx); + descendantSet.add(childTx); + } + }); + while (descendants.length) { + descendantTx = descendants.pop(); + if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { + // remove tx as ancestor + descendantTx.ancestorMap.delete(rootTx.txid); + descendantTx.ancestorFee -= (rootTx.fee + rootTx.feeDelta); + descendantTx.ancestorVsize -= rootTx.adjustedVsize; + descendantTx.ancestorSigops -= rootTx.sigops; + descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorVsize; + descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate; + + if (!descendantTx.modified) { + descendantTx.modified = true; + modified.push(descendantTx); + } + + // add this node's children to the stack + descendantTx.children.forEach(childTx => { + // visit each node only once + if (!descendantSet.has(childTx)) { + descendants.push(childTx); + descendantSet.add(childTx); + } + }); + } + } + // return new, resorted modified list + return modified.sort(priorityComparator); +} + +// Used to sort an array of MinerTransactions by descending ancestor score +function priorityComparator(a: MinerTransaction, b: MinerTransaction): number { + if (b.score === a.score) { + // tie-break by txid for stability + return a.order - b.order; + } else { + return b.score - a.score; + } +} + +// returns the most significant 4 bytes of the txid as an integer +function txidToOrdering(txid: string): number { + return parseInt( + txid.substring(62, 64) + + txid.substring(60, 62) + + txid.substring(58, 60) + + txid.substring(56, 58), + 16 + ); +} diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 3492114b5..8f8bbac82 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -9,6 +9,7 @@ import bitcoinClient from '../bitcoin/bitcoin-client'; import mining from "./mining"; import PricesRepository from '../../repositories/PricesRepository'; import AccelerationRepository from '../../repositories/AccelerationRepository'; +import accelerationApi from '../services/acceleration'; class MiningRoutes { public initRoutes(app: Application) { @@ -24,6 +25,7 @@ class MiningRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments', this.$getDifficultyAdjustments) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', this.$getRewardStats) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', this.$getHistoricalBlockFees) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees', this.$getBlockFeesTimespan) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', this.$getHistoricalBlockRewards) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', this.$getHistoricalBlockFeeRates) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight) @@ -40,6 +42,8 @@ class MiningRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/block/:height', this.$getAccelerationsByHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/recent/:interval', this.$getRecentAccelerations) .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/total', this.$getAccelerationTotals) + .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations', this.$getActiveAccelerations) + .post(config.MEMPOOL.API_URL_PREFIX + 'acceleration/request/:txid', this.$requestAcceleration) ; } @@ -217,6 +221,24 @@ class MiningRoutes { } } + private async $getBlockFeesTimespan(req: Request, res: Response) { + try { + if (!parseInt(req.query.from as string, 10) || !parseInt(req.query.to as string, 10)) { + throw new Error('Invalid timestamp range'); + } + if (parseInt(req.query.from as string, 10) > parseInt(req.query.to as string, 10)) { + throw new Error('from must be less than to'); + } + const blockFees = await mining.$getBlockFeesTimespan(parseInt(req.query.from as string, 10), parseInt(req.query.to as string, 10)); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(blockFees); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async $getHistoricalBlockRewards(req: Request, res: Response) { try { const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval); @@ -426,6 +448,33 @@ class MiningRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getActiveAccelerations(req: Request, res: Response): Promise { + try { + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { + res.status(400).send('Acceleration data is not available.'); + return; + } + res.status(200).send(accelerationApi.accelerations || []); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $requestAcceleration(req: Request, res: Response): Promise { + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Cache-control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); + res.setHeader('expires', -1); + try { + accelerationApi.accelerationRequested(req.params.txid); + res.status(200).send(); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new MiningRoutes(); diff --git a/backend/src/api/mining/mining.ts b/backend/src/api/mining/mining.ts index 85554db2d..21ee4b35a 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -45,11 +45,22 @@ class Mining { */ public async $getHistoricalBlockFees(interval: string | null = null): Promise { return await BlocksRepository.$getHistoricalBlockFees( - this.getTimeRange(interval, 5), + this.getTimeRange(interval), Common.getSqlInterval(interval) ); } + /** + * Get timespan block total fees + */ + public async $getBlockFeesTimespan(from: number, to: number): Promise { + return await BlocksRepository.$getHistoricalBlockFees( + this.getTimeRangeFromTimespan(from, to), + null, + {from, to} + ); + } + /** * Get historical block rewards */ @@ -646,6 +657,24 @@ class Mining { } } + private getTimeRangeFromTimespan(from: number, to: number, scale = 1): number { + const timespan = to - from; + switch (true) { + case timespan > 3600 * 24 * 365 * 4: return 86400 * scale; // 24h + case timespan > 3600 * 24 * 365 * 3: return 43200 * scale; // 12h + case timespan > 3600 * 24 * 365 * 2: return 43200 * scale; // 12h + case timespan > 3600 * 24 * 365: return 28800 * scale; // 8h + case timespan > 3600 * 24 * 30 * 6: return 28800 * scale; // 8h + case timespan > 3600 * 24 * 30 * 3: return 10800 * scale; // 3h + case timespan > 3600 * 24 * 30: return 7200 * scale; // 2h + case timespan > 3600 * 24 * 7: return 1800 * scale; // 30min + case timespan > 3600 * 24 * 3: return 300 * scale; // 5min + case timespan > 3600 * 24: return 1 * scale; + default: return 1 * scale; + } + } + + // Finds the oldest block in a consecutive chain back from the tip // assumes `blocks` is sorted in ascending height order private getOldestConsecutiveBlock(blocks: DifficultyBlock[]): DifficultyBlock { diff --git a/backend/src/api/pools-parser.ts b/backend/src/api/pools-parser.ts index 66f09a9f7..289389d5e 100644 --- a/backend/src/api/pools-parser.ts +++ b/backend/src/api/pools-parser.ts @@ -5,6 +5,9 @@ import PoolsRepository from '../repositories/PoolsRepository'; import { PoolTag } from '../mempool.interfaces'; import diskCache from './disk-cache'; import mining from './mining/mining'; +import transactionUtils from './transaction-utils'; +import BlocksRepository from '../repositories/BlocksRepository'; +import redisCache from './redis-cache'; class PoolsParser { miningPools: any[] = []; @@ -37,28 +40,53 @@ class PoolsParser { /** * Populate our db with updated mining pool definition - * @param pools + * @param pools */ public async migratePoolsJson(): Promise { // We also need to wipe the backend cache to make sure we don't serve blocks with // the wrong mining pool (usually happen with unknown blocks) diskCache.setIgnoreBlocksCache(); + redisCache.setIgnoreBlocksCache(); await this.$insertUnknownPool(); + let reindexUnknown = false; + for (const pool of this.miningPools) { if (!pool.id) { logger.info(`Mining pool ${pool.name} has no unique 'id' defined. Skipping.`); continue; } + // One of the two fields 'addresses' or 'regexes' must be a non-empty array + if (!pool.addresses && !pool.regexes) { + logger.err(`Mining pool ${pool.name} must have at least one of the fields 'addresses' or 'regexes'. Skipping.`); + continue; + } + + pool.addresses = pool.addresses || []; + pool.regexes = pool.regexes || []; + + if (pool.addresses.length === 0 && pool.regexes.length === 0) { + logger.err(`Mining pool ${pool.name} has no 'addresses' nor 'regexes' defined. Skipping.`); + continue; + } + + if (pool.addresses.length === 0) { + logger.warn(`Mining pool ${pool.name} has no 'addresses' defined.`); + } + + if (pool.regexes.length === 0) { + logger.warn(`Mining pool ${pool.name} has no 'regexes' defined.`); + } + const poolDB = await PoolsRepository.$getPoolByUniqueId(pool.id, false); if (!poolDB) { // New mining pool const slug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase(); logger.debug(`Inserting new mining pool ${pool.name}`); await PoolsRepository.$insertNewMiningPool(pool, slug); - await this.$deleteUnknownBlocks(); + reindexUnknown = true; } else { if (poolDB.name !== pool.name) { // Pool has been renamed @@ -76,7 +104,45 @@ class PoolsParser { // Pool addresses changed or coinbase tags changed logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`); await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes); - await this.$deleteBlocksForPool(poolDB); + reindexUnknown = true; + await this.$reindexBlocksForPool(poolDB.id); + } + } + } + + if (reindexUnknown) { + logger.notice(`Updating addresses and/or coinbase tags for unknown mining pool.`); + let unknownPool; + if (config.DATABASE.ENABLED === true) { + unknownPool = await PoolsRepository.$getUnknownPool(); + } else { + unknownPool = this.unknownPool; + } + await this.$reindexBlocksForPool(unknownPool.id); + } + } + + public matchBlockMiner(scriptsig: string, addresses: string[], pools: PoolTag[]): PoolTag | undefined { + const asciiScriptSig = transactionUtils.hex2ascii(scriptsig); + + for (let i = 0; i < pools.length; ++i) { + if (addresses.length) { + const poolAddresses: string[] = typeof pools[i].addresses === 'string' ? + JSON.parse(pools[i].addresses) : pools[i].addresses; + for (let y = 0; y < poolAddresses.length; y++) { + if (addresses.indexOf(poolAddresses[y]) !== -1) { + return pools[i]; + } + } + } + + const regexes: string[] = typeof pools[i].regexes === 'string' ? + JSON.parse(pools[i].regexes) : pools[i].regexes; + for (let y = 0; y < regexes.length; ++y) { + const regex = new RegExp(regexes[y], 'i'); + const match = asciiScriptSig.match(regex); + if (match !== null) { + return pools[i]; } } } @@ -112,68 +178,47 @@ class PoolsParser { } /** - * Delete indexed blocks for an updated mining pool - * - * @param pool + * re-index pool assignment for blocks previously associated with pool + * + * @param pool local id of existing pool to reindex */ - private async $deleteBlocksForPool(pool: PoolTag): Promise { - // Get oldest blocks mined by the pool and assume pools-v2.json updates only concern most recent years - // Ignore early days of Bitcoin as there were no mining pool yet - const [oldestPoolBlock]: any[] = await DB.query(` - SELECT height + private async $reindexBlocksForPool(poolId: number): Promise { + let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52 + if (config.MEMPOOL.NETWORK === 'testnet') { + firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581 + } else if (config.MEMPOOL.NETWORK === 'signet') { + firstKnownBlockPool = 0; + } + + const [blocks]: any[] = await DB.query(` + SELECT height, hash, coinbase_raw, coinbase_addresses FROM blocks WHERE pool_id = ? - ORDER BY height - LIMIT 1`, - [pool.id] - ); + AND height >= ? + ORDER BY height DESC + `, [poolId, firstKnownBlockPool]); - let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52 - if (config.MEMPOOL.NETWORK === 'testnet') { - firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581 - } else if (config.MEMPOOL.NETWORK === 'signet') { - firstKnownBlockPool = 0; + let pools: PoolTag[] = []; + if (config.DATABASE.ENABLED === true) { + pools = await PoolsRepository.$getPools(); + } else { + pools = this.miningPools; } - const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : firstKnownBlockPool; - const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`); - this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`); - await DB.query(` - DELETE FROM blocks - WHERE pool_id = ? AND height >= ${oldestBlockHeight}`, - [unknownPool[0].id] - ); - logger.notice(`Deleting blocks from ${pool.name} mining pool for re-indexing`); - await DB.query(` - DELETE FROM blocks - WHERE pool_id = ?`, - [pool.id] - ); + let changed = 0; + for (const block of blocks) { + const addresses = JSON.parse(block.coinbase_addresses) || []; + const newPool = this.matchBlockMiner(block.coinbase_raw, addresses, pools); + if (newPool && newPool.id !== poolId) { + changed++; + await BlocksRepository.$savePool(block.hash, newPool.id); + } + } + + logger.info(`${changed} blocks assigned to a new pool`, logger.tags.mining); // Re-index hashrates and difficulty adjustments later mining.reindexHashrateRequested = true; - mining.reindexDifficultyAdjustmentRequested = true; - } - - private async $deleteUnknownBlocks(): Promise { - let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52 - if (config.MEMPOOL.NETWORK === 'testnet') { - firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581 - } else if (config.MEMPOOL.NETWORK === 'signet') { - firstKnownBlockPool = 0; - } - - const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`); - this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${firstKnownBlockPool} for re-indexing`); - await DB.query(` - DELETE FROM blocks - WHERE pool_id = ? AND height >= ${firstKnownBlockPool}`, - [unknownPool[0].id] - ); - - // Re-index hashrates and difficulty adjustments later - mining.reindexHashrateRequested = true; - mining.reindexDifficultyAdjustmentRequested = true; } } diff --git a/backend/src/api/redis-cache.ts b/backend/src/api/redis-cache.ts index d19d73a7f..cbfa2f18b 100644 --- a/backend/src/api/redis-cache.ts +++ b/backend/src/api/redis-cache.ts @@ -27,6 +27,7 @@ class RedisCache { private rbfCacheQueue: { type: string, txid: string, value: any }[] = []; private rbfRemoveQueue: { type: string, txid: string }[] = []; private txFlushLimit: number = 10000; + private ignoreBlocksCache = false; constructor() { if (config.REDIS.ENABLED) { @@ -155,7 +156,7 @@ class RedisCache { const toAdd = this.cacheQueue.slice(0, this.txFlushLimit); try { const msetData = toAdd.map(tx => { - const minified: any = { ...tx }; + const minified: any = structuredClone(tx); delete minified.hex; for (const vin of minified.vin) { delete vin.inner_redeemscript_asm; @@ -341,9 +342,7 @@ class RedisCache { return; } logger.info('Restoring mempool and blocks data from Redis cache'); - // Load block data - const loadedBlocks = await this.$getBlocks(); - const loadedBlockSummaries = await this.$getBlockSummaries(); + // Load mempool const loadedMempool = await this.$getMempool(); this.inflateLoadedTxs(loadedMempool); @@ -352,9 +351,14 @@ class RedisCache { const rbfTrees = await this.$getRbfEntries('tree'); const rbfExpirations = await this.$getRbfEntries('exp'); - // Set loaded data - blocks.setBlocks(loadedBlocks || []); - blocks.setBlockSummaries(loadedBlockSummaries || []); + // Load & set block data + if (!this.ignoreBlocksCache) { + const loadedBlocks = await this.$getBlocks(); + const loadedBlockSummaries = await this.$getBlockSummaries(); + blocks.setBlocks(loadedBlocks || []); + blocks.setBlockSummaries(loadedBlockSummaries || []); + } + // Set other data await memPool.$setMempool(loadedMempool); await rbfCache.load({ txs: rbfTxs, @@ -411,6 +415,10 @@ class RedisCache { } return result; } + + public setIgnoreBlocksCache(): void { + this.ignoreBlocksCache = true; + } } export default new RedisCache(); diff --git a/backend/src/api/services/acceleration.ts b/backend/src/api/services/acceleration.ts index f22959f3f..88289382b 100644 --- a/backend/src/api/services/acceleration.ts +++ b/backend/src/api/services/acceleration.ts @@ -1,12 +1,23 @@ import config from '../../config'; import logger from '../../logger'; -import { BlockExtended, PoolTag } from '../../mempool.interfaces'; +import { BlockExtended } from '../../mempool.interfaces'; import axios from 'axios'; +type MyAccelerationStatus = 'requested' | 'accelerating' | 'done'; + export interface Acceleration { txid: string, + added: number, + effectiveVsize: number, + effectiveFee: number, feeDelta: number, pools: number[], + positions?: { + [pool: number]: { + block: number, + vbytes: number, + }, + }, }; export interface AccelerationHistory { @@ -22,25 +33,95 @@ export interface AccelerationHistory { feeDelta: number, blockHash: string, blockHeight: number, - pools: { - pool_unique_id: number, - username: string, - }[], + pools: number[]; }; class AccelerationApi { - public async $fetchAccelerations(): Promise { - if (config.MEMPOOL_SERVICES.ACCELERATIONS) { - try { - const response = await axios.get(`${config.MEMPOOL_SERVICES.API}/accelerator/accelerations`, { responseType: 'json', timeout: 10000 }); - return response.data as Acceleration[]; - } catch (e) { - logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e)); - return null; + private onDemandPollingEnabled = !config.MEMPOOL_SERVICES.ACCELERATIONS; + private apiPath = config.MEMPOOL.OFFICIAL ? (config.MEMPOOL_SERVICES.API + '/accelerator/accelerations') : (config.EXTERNAL_DATA_SERVER.MEMPOOL_API + '/accelerations'); + private _accelerations: Acceleration[] | null = null; + private lastPoll = 0; + private forcePoll = false; + private myAccelerations: Record = {}; + + public get accelerations(): Acceleration[] | null { + return this._accelerations; + } + + public countMyAccelerationsWithStatus(filter: MyAccelerationStatus): number { + return Object.values(this.myAccelerations).reduce((count, {status}) => { return count + (status === filter ? 1 : 0); }, 0); + } + + public accelerationRequested(txid: string): void { + if (this.onDemandPollingEnabled) { + this.myAccelerations[txid] = { status: 'requested', added: Date.now() }; + } + } + + public accelerationConfirmed(): void { + this.forcePoll = true; + } + + private async $fetchAccelerations(): Promise { + try { + const response = await axios.get(this.apiPath, { responseType: 'json', timeout: 10000 }); + return response?.data || []; + } catch (e) { + logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e)); + return null; + } + } + + public async $updateAccelerations(): Promise { + if (!this.onDemandPollingEnabled) { + const accelerations = await this.$fetchAccelerations(); + if (accelerations) { + this._accelerations = accelerations; + return this._accelerations; } } else { - return []; + return this.$updateAccelerationsOnDemand(); } + return null; + } + + private async $updateAccelerationsOnDemand(): Promise { + const shouldUpdate = this.forcePoll + || this.countMyAccelerationsWithStatus('requested') > 0 + || (this.countMyAccelerationsWithStatus('accelerating') > 0 && this.lastPoll < (Date.now() - (10 * 60 * 1000))); + + // update accelerations if necessary + if (shouldUpdate) { + const accelerations = await this.$fetchAccelerations(); + this.lastPoll = Date.now(); + this.forcePoll = false; + if (accelerations) { + const latestAccelerations: Record = {}; + // set relevant accelerations to 'accelerating' + for (const acc of accelerations) { + if (this.myAccelerations[acc.txid]) { + latestAccelerations[acc.txid] = acc; + this.myAccelerations[acc.txid] = { status: 'accelerating', added: Date.now(), acceleration: acc }; + } + } + // txs that are no longer accelerating are either confirmed or canceled, so mark for expiry + for (const [txid, { status, acceleration }] of Object.entries(this.myAccelerations)) { + if (status === 'accelerating' && !latestAccelerations[txid]) { + this.myAccelerations[txid] = { status: 'done', added: Date.now(), acceleration }; + } + } + } + } + + // clear expired accelerations (confirmed / failed / not accepted) after 10 minutes + for (const [txid, { status, added }] of Object.entries(this.myAccelerations)) { + if (['requested', 'done'].includes(status) && added < (Date.now() - (1000 * 60 * 10))) { + delete this.myAccelerations[txid]; + } + } + + this._accelerations = Object.values(this.myAccelerations).map(({ acceleration }) => acceleration).filter(acc => acc) as Acceleration[]; + return this._accelerations; } public async $fetchAccelerationHistory(page?: number, status?: string): Promise { diff --git a/backend/src/api/statistics/statistics-api.ts b/backend/src/api/statistics/statistics-api.ts index c7c3f37b0..2d66d69d9 100644 --- a/backend/src/api/statistics/statistics-api.ts +++ b/backend/src/api/statistics/statistics-api.ts @@ -64,7 +64,7 @@ class StatisticsApi { } } - public async $create(statistics: Statistic): Promise { + public async $create(statistics: Statistic, convertToDatetime = false): Promise { try { const query = `INSERT INTO statistics( added, @@ -114,7 +114,7 @@ class StatisticsApi { vsize_1800, vsize_2000 ) - VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + VALUES (${convertToDatetime ? `FROM_UNIXTIME(${statistics.added})` : statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; const params: (string | number)[] = [ @@ -456,6 +456,59 @@ class StatisticsApi { }; }); } + + public mapOptimizedStatisticToStatistic(statistic: OptimizedStatistic[]): Statistic[] { + return statistic.map((s) => { + return { + added: s.added, + unconfirmed_transactions: s.count, + tx_per_second: 0, + vbytes_per_second: s.vbytes_per_second, + mempool_byte_weight: s.mempool_byte_weight || 0, + total_fee: s.total_fee || 0, + min_fee: s.min_fee, + fee_data: '', + vsize_1: s.vsizes[0], + vsize_2: s.vsizes[1], + vsize_3: s.vsizes[2], + vsize_4: s.vsizes[3], + vsize_5: s.vsizes[4], + vsize_6: s.vsizes[5], + vsize_8: s.vsizes[6], + vsize_10: s.vsizes[7], + vsize_12: s.vsizes[8], + vsize_15: s.vsizes[9], + vsize_20: s.vsizes[10], + vsize_30: s.vsizes[11], + vsize_40: s.vsizes[12], + vsize_50: s.vsizes[13], + vsize_60: s.vsizes[14], + vsize_70: s.vsizes[15], + vsize_80: s.vsizes[16], + vsize_90: s.vsizes[17], + vsize_100: s.vsizes[18], + vsize_125: s.vsizes[19], + vsize_150: s.vsizes[20], + vsize_175: s.vsizes[21], + vsize_200: s.vsizes[22], + vsize_250: s.vsizes[23], + vsize_300: s.vsizes[24], + vsize_350: s.vsizes[25], + vsize_400: s.vsizes[26], + vsize_500: s.vsizes[27], + vsize_600: s.vsizes[28], + vsize_700: s.vsizes[29], + vsize_800: s.vsizes[30], + vsize_900: s.vsizes[31], + vsize_1000: s.vsizes[32], + vsize_1200: s.vsizes[33], + vsize_1400: s.vsizes[34], + vsize_1600: s.vsizes[35], + vsize_1800: s.vsizes[36], + vsize_2000: s.vsizes[37], + } + }); + } } export default new StatisticsApi(); diff --git a/backend/src/api/transaction-utils.ts b/backend/src/api/transaction-utils.ts index 9107f2ae7..15d3e7110 100644 --- a/backend/src/api/transaction-utils.ts +++ b/backend/src/api/transaction-utils.ts @@ -103,7 +103,7 @@ class TransactionUtils { } const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4); const transactionExtended: TransactionExtended = Object.assign({ - vsize: Math.round(transaction.weight / 4), + vsize: transaction.weight / 4, feePerVsize: feePerVbytes, effectiveFeePerVsize: feePerVbytes, }, transaction); @@ -123,7 +123,7 @@ class TransactionUtils { const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize; const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, { order: this.txidToOrdering(transaction.txid), - vsize: Math.round(transaction.weight / 4), + vsize, adjustedVsize, sigops, feePerVsize: feePerVbytes, @@ -338,6 +338,87 @@ class TransactionUtils { const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2; return witness[positionOfScript]; } + + // calculate the most parsimonious set of prioritizations given a list of block transactions + // (i.e. the most likely prioritizations and deprioritizations) + public identifyPrioritizedTransactions(transactions: any[], rateKey: string): { prioritized: string[], deprioritized: string[] } { + // find the longest increasing subsequence of transactions + // (adapted from https://en.wikipedia.org/wiki/Longest_increasing_subsequence#Efficient_algorithms) + // should be O(n log n) + const X = transactions.slice(1).reverse().map((tx) => ({ txid: tx.txid, rate: tx[rateKey] })); // standard block order is by *decreasing* effective fee rate, but we want to iterate in increasing order (and skip the coinbase) + if (X.length < 2) { + return { prioritized: [], deprioritized: [] }; + } + const N = X.length; + const P: number[] = new Array(N); + const M: number[] = new Array(N + 1); + M[0] = -1; // undefined so can be set to any value + + let L = 0; + for (let i = 0; i < N; i++) { + // Binary search for the smallest positive l ≤ L + // such that X[M[l]].effectiveFeePerVsize > X[i].effectiveFeePerVsize + let lo = 1; + let hi = L + 1; + while (lo < hi) { + const mid = lo + Math.floor((hi - lo) / 2); // lo <= mid < hi + if (X[M[mid]].rate > X[i].rate) { + hi = mid; + } else { // if X[M[mid]].effectiveFeePerVsize < X[i].effectiveFeePerVsize + lo = mid + 1; + } + } + + // After searching, lo == hi is 1 greater than the + // length of the longest prefix of X[i] + const newL = lo; + + // The predecessor of X[i] is the last index of + // the subsequence of length newL-1 + P[i] = M[newL - 1]; + M[newL] = i; + + if (newL > L) { + // If we found a subsequence longer than any we've + // found yet, update L + L = newL; + } + } + + // Reconstruct the longest increasing subsequence + // It consists of the values of X at the L indices: + // ..., P[P[M[L]]], P[M[L]], M[L] + const LIS: any[] = new Array(L); + let k = M[L]; + for (let j = L - 1; j >= 0; j--) { + LIS[j] = X[k]; + k = P[k]; + } + + const lisMap = new Map(); + LIS.forEach((tx, index) => lisMap.set(tx.txid, index)); + + const prioritized: string[] = []; + const deprioritized: string[] = []; + + let lastRate = X[0].rate; + + for (const tx of X) { + if (lisMap.has(tx.txid)) { + lastRate = tx.rate; + } else { + if (Math.abs(tx.rate - lastRate) < 0.1) { + // skip if the rate is almost the same as the previous transaction + } else if (tx.rate <= lastRate) { + prioritized.push(tx.txid); + } else { + deprioritized.push(tx.txid); + } + } + } + + return { prioritized, deprioritized }; + } } export default new TransactionUtils(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index ef4a02d4f..79a783f88 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -3,6 +3,7 @@ import * as WebSocket from 'ws'; import { BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse, OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo, + MempoolDelta, MempoolDeltaTxids } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; @@ -32,7 +33,7 @@ interface AddressTransactions { removed: MempoolTransactionExtended[], } import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; -import { calculateCpfp } from './cpfp'; +import { calculateMempoolTxCpfp } from './cpfp'; // valid 'want' subscriptions const wantable = [ @@ -44,7 +45,7 @@ const wantable = [ ]; class WebsocketHandler { - private wss: WebSocket.Server | undefined; + private webSocketServers: WebSocket.Server[] = []; private extraInitProperties = {}; private numClients = 0; @@ -54,11 +55,12 @@ class WebsocketHandler { private socketData: { [key: string]: string } = {}; private serializedInitData: string = '{}'; private lastRbfSummary: ReplacementInfo[] | null = null; + private mempoolSequence: number = 0; constructor() { } - setWebsocketServer(wss: WebSocket.Server) { - this.wss = wss; + addWebsocketServer(wss: WebSocket.Server) { + this.webSocketServers.push(wss); } setExtraInitData(property: string, value: any) { @@ -102,11 +104,13 @@ class WebsocketHandler { } setupConnectionHandling() { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } - this.wss.on('connection', (client: WebSocket, req) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.on('connection', (client: WebSocket, req) => { this.numConnected++; client['remoteAddress'] = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown'; client.on('error', (e) => { @@ -202,7 +206,8 @@ class WebsocketHandler { } response['txPosition'] = JSON.stringify({ txid: trackTxid, - position + position, + accelerationPositions: memPool.getAccelerationPositions(tx.txid), }); } } else { @@ -315,6 +320,7 @@ class WebsocketHandler { const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); response['projected-block-transactions'] = JSON.stringify({ index: index, + sequence: this.mempoolSequence, blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx), }); } else { @@ -342,6 +348,17 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-accelerations'] != null) { + if (parsedMessage['track-accelerations']) { + client['track-accelerations'] = true; + response['accelerations'] = JSON.stringify({ + accelerations: Object.values(memPool.getAccelerations()), + }); + } else { + client['track-accelerations'] = false; + } + } + if (parsedMessage.action === 'init') { if (!this.socketData['blocks']?.length || !this.socketData['da'] || !this.socketData['backendInfo'] || !this.socketData['conversions']) { this.updateSocketData(); @@ -360,6 +377,18 @@ class WebsocketHandler { client['track-donation'] = parsedMessage['track-donation']; } + if (parsedMessage['track-mempool-txids'] === true) { + client['track-mempool-txids'] = true; + } else if (parsedMessage['track-mempool-txids'] === false) { + delete client['track-mempool-txids']; + } + + if (parsedMessage['track-mempool'] === true) { + client['track-mempool'] = true; + } else if (parsedMessage['track-mempool'] === false) { + delete client['track-mempool']; + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -369,14 +398,17 @@ class WebsocketHandler { } }); }); + } } handleNewDonation(id: string) { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } @@ -384,43 +416,50 @@ class WebsocketHandler { client.send(JSON.stringify({ donationConfirmed: true })); } }); + } } handleLoadingChanged(indicators: ILoadingIndicators) { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } this.updateSocketDataFields({ 'loadingIndicators': indicators }); const response = JSON.stringify({ loadingIndicators: indicators }); - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } client.send(response); }); + } } handleNewConversionRates(conversionRates: ApiPrice) { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } this.updateSocketDataFields({ 'conversions': conversionRates }); const response = JSON.stringify({ conversions: conversionRates }); - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } client.send(response); }); + } } handleNewStatistic(stats: OptimizedStatistic) { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } this.printLogs(); @@ -429,7 +468,9 @@ class WebsocketHandler { 'live-2h-chart': stats }); - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } @@ -440,11 +481,12 @@ class WebsocketHandler { client.send(response); }); + } } handleReorg(): void { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } const da = difficultyAdjustment.getDifficultyAdjustment(); @@ -455,7 +497,9 @@ class WebsocketHandler { 'da': da?.previousTime ? da : undefined, }); - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } @@ -473,13 +517,14 @@ class WebsocketHandler { client.send(this.serializeResponse(response)); } }); + } } async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[], candidates?: GbtCandidates): Promise { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } this.printLogs(); @@ -493,9 +538,9 @@ class WebsocketHandler { } if (config.MEMPOOL.RUST_GBT) { - await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, true); } else { - await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, true); } const mBlocks = mempoolBlocks.getMempoolBlocks(); @@ -504,6 +549,7 @@ class WebsocketHandler { const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const da = difficultyAdjustment.getDifficultyAdjustment(); + const accelerations = memPool.getAccelerations(); memPool.handleRbfTransactions(rbfTransactions); const rbfChanges = rbfCache.getRbfChanges(); let rbfReplacements; @@ -525,6 +571,33 @@ class WebsocketHandler { const latestTransactions = memPool.getLatestTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const tx of newTransactions) { + if (rbfTransactions[tx.txid]) { + for (const replaced of rbfTransactions[tx.txid]) { + replacedTransactions.push({ replaced: replaced.txid, by: tx }); + } + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: newTransactions.map(tx => tx.txid), + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: newTransactions, + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions, + }; + // update init data const socketDataFields = { 'mempoolInfo': mempoolInfo, @@ -552,7 +625,9 @@ class WebsocketHandler { // pre-compute new tracked outspends const outspendCache: { [txid: string]: { [vout: number]: { vin: number, txid: string } } } = {}; const trackedTxs = new Set(); - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client['track-tx']) { trackedTxs.add(client['track-tx']); } @@ -562,6 +637,7 @@ class WebsocketHandler { } } }); + } if (trackedTxs.size > 0) { for (const tx of newTransactions) { for (let i = 0; i < tx.vin.length; i++) { @@ -581,7 +657,15 @@ class WebsocketHandler { const addressCache = this.makeAddressCache(newTransactions); const removedAddressCache = this.makeAddressCache(deletedTransactions); - this.wss.clients.forEach(async (client) => { + // pre-compute acceleration delta + const accelerationUpdate = { + added: accelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null), + removed: accelerationDelta.filter(txid => !accelerations[txid]), + }; + + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach(async (client) => { if (client.readyState !== WebSocket.OPEN) { return; } @@ -737,10 +821,14 @@ class WebsocketHandler { position: { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, - } + acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, + feeDelta: mempoolTx.feeDelta || undefined, + }, + accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }; if (!mempoolTx.cpfpChecked && !mempoolTx.acceleration) { - calculateCpfp(mempoolTx, newMempool); + calculateMempoolTxCpfp(mempoolTx, newMempool); } if (mempoolTx.cpfpDirty) { positionData['cpfp'] = { @@ -750,7 +838,7 @@ class WebsocketHandler { effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null, sigops: mempoolTx.sigops, adjustedVsize: mempoolTx.adjustedVsize, - acceleration: mempoolTx.acceleration + acceleration: mempoolTx.acceleration, }; } response['txPosition'] = JSON.stringify(positionData); @@ -775,9 +863,12 @@ class WebsocketHandler { txInfo.position = { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, + acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, + feeDelta: mempoolTx.feeDelta || undefined, }; if (!mempoolTx.cpfpChecked) { - calculateCpfp(mempoolTx, newMempool); + calculateMempoolTxCpfp(mempoolTx, newMempool); } if (mempoolTx.cpfpDirty) { txInfo.cpfp = { @@ -802,6 +893,7 @@ class WebsocketHandler { if (mBlockDeltas[index]) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { index: index, + sequence: this.mempoolSequence, delta: mBlockDeltas[index], }); } @@ -817,17 +909,32 @@ class WebsocketHandler { response['rbfLatestSummary'] = getCachedResponse('rbfLatestSummary', rbfSummary); } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + + if (client['track-accelerations'] && (accelerationUpdate.added.length || accelerationUpdate.removed.length)) { + response['accelerations'] = getCachedResponse('accelerations', accelerationUpdate); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } }); + } } async handleNewBlock(block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]): Promise { - if (!this.wss) { - throw new Error('WebSocket.Server is not set'); + if (!this.webSocketServers.length) { + throw new Error('No WebSocket.Server have been set'); } + const blockTransactions = structuredClone(transactions); + this.printLogs(); await statistics.runStatistics(); @@ -837,7 +944,7 @@ class WebsocketHandler { let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool); const accelerations = Object.values(mempool.getAccelerations()); - await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, transactions); + await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions)); const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap()); memPool.handleMinedRbfTransactions(rbfTransactions); @@ -846,22 +953,18 @@ class WebsocketHandler { if (config.MEMPOOL.AUDIT && memPool.isInSync()) { let projectedBlocks; const auditMempool = _memPool; - const isAccelerated = config.MEMPOOL_SERVICES.ACCELERATIONS && accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations())); + const isAccelerated = accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations())); - if ((config.MEMPOOL_SERVICES.ACCELERATIONS)) { - if (config.MEMPOOL.RUST_GBT) { - const added = memPool.limitGBT ? (candidates?.added || []) : []; - const removed = memPool.limitGBT ? (candidates?.removed || []) : []; - projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id); - } else { - projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id); - } + if (config.MEMPOOL.RUST_GBT) { + const added = memPool.limitGBT ? (candidates?.added || []) : []; + const removed = memPool.limitGBT ? (candidates?.removed || []) : []; + projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id); } else { - projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); + projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id); } if (Common.indexingEnabled()) { - const { censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); + const { unseen, censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(block.height, blockTransactions, projectedBlocks, auditMempool); const matchRate = Math.round(score * 100 * 100) / 100; const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : []; @@ -883,9 +986,11 @@ class WebsocketHandler { }); BlocksAuditsRepository.$saveAudit({ + version: 1, time: block.timestamp, height: block.height, hash: block.id, + unseenTxs: unseen, addedTxs: added, prioritizedTxs: prioritized, missingTxs: censored, @@ -937,7 +1042,7 @@ class WebsocketHandler { const removed = memPool.limitGBT ? (candidates?.removed || []) : transactions; await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, _memPool, added, removed, candidates, true); } else { - await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, true); } const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); @@ -961,6 +1066,31 @@ class WebsocketHandler { const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const txid of Object.keys(rbfTransactions)) { + for (const replaced of rbfTransactions[txid].replaced) { + replacedTransactions.push({ replaced: replaced.txid, by: rbfTransactions[txid].replacedBy }); + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions, + }; + const responseCache = { ...this.socketData }; function getCachedResponse(key, data): string { if (!responseCache[key]) { @@ -969,7 +1099,9 @@ class WebsocketHandler { return responseCache[key]; } - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } @@ -1010,7 +1142,11 @@ class WebsocketHandler { position: { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, - } + acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, + feeDelta: mempoolTx.feeDelta || undefined, + }, + accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }); } } @@ -1029,6 +1165,9 @@ class WebsocketHandler { ...mempoolTx.position, }, accelerated: mempoolTx.acceleration || undefined, + acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, + feeDelta: mempoolTx.feeDelta || undefined, }; } } @@ -1135,21 +1274,32 @@ class WebsocketHandler { if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-full-${index}`, { index: index, + sequence: this.mempoolSequence, blockTransactions: mBlocksWithTransactions[index].transactions.map(mempoolBlocks.compressTx), }); } else { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-delta-${index}`, { index: index, + sequence: this.mempoolSequence, delta: mBlockDeltas[index], }); } } } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } }); + } await statistics.runStatistics(); } @@ -1158,7 +1308,7 @@ class WebsocketHandler { // and zips it together into a valid JSON object private serializeResponse(response): string { return '{' - + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') + + Object.keys(response).filter(key => response[key] != null).map(key => `"${key}": ${response[key]}`).join(', ') + '}'; } @@ -1231,13 +1381,15 @@ class WebsocketHandler { } private printLogs(): void { - if (this.wss) { + if (this.webSocketServers.length) { let numTxSubs = 0; let numTxsSubs = 0; let numProjectedSubs = 0; let numRbfSubs = 0; - this.wss.clients.forEach((client) => { + // TODO - Fix indentation after PR is merged + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { if (client['track-tx']) { numTxSubs++; } @@ -1251,8 +1403,12 @@ class WebsocketHandler { numRbfSubs++; } }) + } - const count = this.wss?.clients?.size || 0; + let count = 0; + for (const server of this.webSocketServers) { + count += server.clients?.size || 0; + } const diff = count - this.numClients; this.numClients = count; logger.debug(`${count} websocket clients | ${this.numConnected} connected | ${this.numDisconnected} disconnected | (${diff >= 0 ? '+' : ''}${diff})`); diff --git a/backend/src/config.ts b/backend/src/config.ts index 93ac90834..b0afe7f23 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -9,6 +9,7 @@ interface IConfig { NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet'; BACKEND: 'esplora' | 'electrum' | 'none'; HTTP_PORT: number; + UNIX_SOCKET_PATH: string; SPAWN_CLUSTER_PROCS: number; API_URL_PREFIX: string; POLL_RATE_MS: number; @@ -28,7 +29,7 @@ interface IConfig { EXTERNAL_RETRY_INTERVAL: number; USER_AGENT: string; STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug'; - AUTOMATIC_BLOCK_REINDEXING: boolean; + AUTOMATIC_POOLS_UPDATE: boolean; POOLS_JSON_URL: string, POOLS_JSON_TREE_URL: string, AUDIT: boolean; @@ -50,6 +51,7 @@ interface IConfig { REQUEST_TIMEOUT: number; FALLBACK_TIMEOUT: number; FALLBACK: string[]; + MAX_BEHIND_TIP: number; }; LIGHTNING: { ENABLED: boolean; @@ -140,6 +142,8 @@ interface IConfig { ENABLED: boolean; AUDIT: boolean; AUDIT_START_HEIGHT: number; + STATISTICS: boolean; + STATISTICS_START_TIME: number | string; SERVERS: string[]; }, MEMPOOL_SERVICES: { @@ -153,6 +157,7 @@ interface IConfig { }, FIAT_PRICE: { ENABLED: boolean; + PAID: boolean; API_KEY: string; }, } @@ -164,6 +169,7 @@ const defaults: IConfig = { 'NETWORK': 'mainnet', 'BACKEND': 'none', 'HTTP_PORT': 8999, + 'UNIX_SOCKET_PATH': '', 'SPAWN_CLUSTER_PROCS': 0, 'API_URL_PREFIX': '/api/v1/', 'POLL_RATE_MS': 2000, @@ -183,7 +189,7 @@ const defaults: IConfig = { 'EXTERNAL_RETRY_INTERVAL': 0, 'USER_AGENT': 'mempool', 'STDOUT_LOG_MIN_PRIORITY': 'debug', - 'AUTOMATIC_BLOCK_REINDEXING': false, + 'AUTOMATIC_POOLS_UPDATE': false, 'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', 'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', 'AUDIT': false, @@ -205,6 +211,7 @@ const defaults: IConfig = { 'REQUEST_TIMEOUT': 10000, 'FALLBACK_TIMEOUT': 5000, 'FALLBACK': [], + 'MAX_BEHIND_TIP': 2, }, 'ELECTRUM': { 'HOST': '127.0.0.1', @@ -295,6 +302,8 @@ const defaults: IConfig = { 'ENABLED': false, 'AUDIT': false, 'AUDIT_START_HEIGHT': 774000, + 'STATISTICS': false, + 'STATISTICS_START_TIME': 1481932800, 'SERVERS': [], }, 'MEMPOOL_SERVICES': { @@ -308,6 +317,7 @@ const defaults: IConfig = { }, 'FIAT_PRICE': { 'ENABLED': true, + 'PAID': false, 'API_KEY': '', }, }; diff --git a/backend/src/database.ts b/backend/src/database.ts index 05f624ff4..595b88c78 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -2,8 +2,7 @@ import * as fs from 'fs'; import path from 'path'; import config from './config'; import { createPool, Pool, PoolConnection } from 'mysql2/promise'; -import { LogLevel } from './logger'; -import logger from './logger'; +import logger, { LogLevel } from './logger'; import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } from 'mysql2/typings/mysql'; import { execSync } from 'child_process'; diff --git a/backend/src/index.ts b/backend/src/index.ts index 0b2cbb003..1d83c56a3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,10 +45,13 @@ import bitcoinCoreRoutes from './api/bitcoin/bitcoin-core.routes'; import bitcoinSecondClient from './api/bitcoin/bitcoin-second-client'; import accelerationRoutes from './api/acceleration/acceleration.routes'; import aboutRoutes from './api/about.routes'; +import mempoolBlocks from './api/mempool-blocks'; class Server { private wss: WebSocket.Server | undefined; + private wssUnixSocket: WebSocket.Server | undefined; private server: http.Server | undefined; + private serverUnixSocket: http.Server | undefined; private app: Application; private currentBackendRetryInterval = 1; private backendRetryCount = 0; @@ -129,6 +132,7 @@ class Server { }) .use(express.urlencoded({ extended: true })) .use(express.text({ type: ['text/plain', 'application/base64'] })) + .use(express.json()) ; if (config.DATABASE.ENABLED && config.FIAT_PRICE.ENABLED) { @@ -137,11 +141,16 @@ class Server { this.server = http.createServer(this.app); this.wss = new WebSocket.Server({ server: this.server }); + if (config.MEMPOOL.UNIX_SOCKET_PATH) { + this.serverUnixSocket = http.createServer(this.app); + this.wssUnixSocket = new WebSocket.Server({ server: this.serverUnixSocket }); + } this.setUpWebsocketHandling(); await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await syncAssets.syncAssets$(); + await mempoolBlocks.updatePools$(); if (config.MEMPOOL.ENABLED) { if (config.MEMPOOL.CACHE_ENABLED) { await diskCache.$loadMempoolCache(); @@ -192,6 +201,16 @@ class Server { logger.notice(`Mempool Server is running on port ${config.MEMPOOL.HTTP_PORT}`); } }); + + if (this.serverUnixSocket) { + this.serverUnixSocket.listen(config.MEMPOOL.UNIX_SOCKET_PATH, () => { + if (worker) { + logger.info(`Mempool Server worker #${process.pid} started`); + } else { + logger.notice(`Mempool Server is listening on ${config.MEMPOOL.UNIX_SOCKET_PATH}`); + } + }); + } } async runMainUpdateLoop(): Promise { @@ -210,7 +229,7 @@ class Server { const newMempool = await bitcoinApi.$getRawMempool(); const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null; const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1; - const newAccelerations = await accelerationApi.$fetchAccelerations(); + const newAccelerations = await accelerationApi.$updateAccelerations(); const numHandledBlocks = await blocks.$updateBlocks(); const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1); if (numHandledBlocks === 0) { @@ -265,8 +284,12 @@ class Server { setUpWebsocketHandling(): void { if (this.wss) { - websocketHandler.setWebsocketServer(this.wss); + websocketHandler.addWebsocketServer(this.wss); } + if (this.wssUnixSocket) { + websocketHandler.addWebsocketServer(this.wssUnixSocket); + } + if (Common.isLiquid() && config.DATABASE.ENABLED) { blocks.setNewBlockCallback(async () => { try { @@ -310,7 +333,9 @@ class Server { if (config.MEMPOOL_SERVICES.ACCELERATIONS) { accelerationRoutes.initRoutes(this.app); } - aboutRoutes.initRoutes(this.app); + if (!config.MEMPOOL.OFFICIAL) { + aboutRoutes.initRoutes(this.app); + } } healthCheck(): void { @@ -338,6 +363,12 @@ class Server { if (config.DATABASE.ENABLED) { DB.releasePidLock(); } + this.server?.close(); + this.serverUnixSocket?.close(); + this.wss?.close(); + if (this.wssUnixSocket) { + this.wssUnixSocket.close(); + } process.exit(code); } diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index bc169630f..dfd7f1317 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -8,7 +8,9 @@ import priceUpdater from './tasks/price-updater'; import PricesRepository from './repositories/PricesRepository'; import config from './config'; import auditReplicator from './replication/AuditReplication'; +import statisticsReplicator from './replication/StatisticsReplication'; import AccelerationRepository from './repositories/AccelerationRepository'; +import BlocksAuditsRepository from './repositories/BlocksAuditsRepository'; export interface CoreIndex { name: string; @@ -181,6 +183,7 @@ class Indexer { } this.runSingleTask('blocksPrices'); + await blocks.$indexCoinbaseAddresses(); await mining.$indexDifficultyAdjustments(); await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); @@ -188,7 +191,9 @@ class Indexer { await blocks.$generateCPFPDatabase(); await blocks.$generateAuditStats(); await auditReplicator.$sync(); + await statisticsReplicator.$sync(); await AccelerationRepository.$indexPastAccelerations(); + await BlocksAuditsRepository.$migrateAuditsV0toV1(); // do not wait for classify blocks to finish blocks.$classifyBlocks(); } catch (e) { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 0b4b20e02..ccbc94bfa 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -29,9 +29,11 @@ export interface PoolStats extends PoolInfo { } export interface BlockAudit { + version: number, time: number, height: number, hash: string, + unseenTxs: string[], missingTxs: string[], freshTxs: string[], sigopTxs: string[], @@ -42,6 +44,19 @@ export interface BlockAudit { matchRate: number, expectedFees?: number, expectedWeight?: number, + template?: any[]; +} + +export interface TransactionAudit { + seen?: boolean; + expected?: boolean; + added?: boolean; + prioritized?: boolean; + delayed?: number; + accelerated?: boolean; + conflict?: boolean; + coinbase?: boolean; + firstSeen?: number; } export interface AuditScore { @@ -71,6 +86,22 @@ export interface MempoolBlockDelta { changed: MempoolDeltaChange[]; } +export interface MempoolDeltaTxids { + sequence: number, + added: string[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: string }[]; +} + +export interface MempoolDelta { + sequence: number, + added: MempoolTransactionExtended[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: TransactionExtended }[]; +} + interface VinStrippedToScriptsig { scriptsig: string; } @@ -95,6 +126,9 @@ export interface TransactionExtended extends IEsploraApi.Transaction { vsize: number, }; acceleration?: boolean; + acceleratedBy?: number[]; + acceleratedAt?: number; + feeDelta?: number; replacement?: boolean; uid?: number; flags?: number; @@ -192,6 +226,7 @@ export interface CpfpInfo { sigops?: number; adjustedVsize?: number, acceleration?: boolean, + fee?: number; } export interface TransactionStripped { @@ -270,6 +305,7 @@ export interface BlockExtension { coinbaseRaw: string; orphans: OrphanedBlock[] | null; coinbaseAddress: string | null; + coinbaseAddresses: string[] | null; coinbaseSignature: string | null; coinbaseSignatureAscii: string | null; virtualSize: number; @@ -349,8 +385,9 @@ export interface CpfpCluster { } export interface CpfpSummary { - transactions: TransactionExtended[]; + transactions: MempoolTransactionExtended[]; clusters: CpfpCluster[]; + version: number; } export interface Statistic { @@ -406,6 +443,7 @@ export interface Statistic { export interface OptimizedStatistic { added: string; + count: number; vbytes_per_second: number; total_fee: number; mempool_byte_weight: number; @@ -415,7 +453,7 @@ export interface OptimizedStatistic { export interface TxTrackingInfo { replacedBy?: string, - position?: { block: number, vsize: number, accelerated?: boolean }, + position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number, feeDelta?: number }, cpfp?: { ancestors?: Ancestor[], bestDescendant?: Ancestor | null, @@ -426,6 +464,9 @@ export interface TxTrackingInfo { }, utxoSpent?: { [vout: number]: { vin: number, txid: string } }, accelerated?: boolean, + acceleratedBy?: number[], + acceleratedAt?: number, + feeDelta?: number, confirmed?: boolean } diff --git a/backend/src/replication/AuditReplication.ts b/backend/src/replication/AuditReplication.ts index 4ea629839..6f616dbbe 100644 --- a/backend/src/replication/AuditReplication.ts +++ b/backend/src/replication/AuditReplication.ts @@ -31,11 +31,11 @@ class AuditReplication { const missingAudits = await this.$getMissingAuditBlocks(); logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication'); - + let totalSynced = 0; let totalMissed = 0; let loggerTimer = Date.now(); - // process missing audits in batches of + // process missing audits in batches of BATCH_SIZE for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) { const slice = missingAudits.slice(i, i + BATCH_SIZE); const results = await Promise.all(slice.map(hash => this.$syncAudit(hash))); @@ -109,9 +109,11 @@ class AuditReplication { version: 1, }); await blocksAuditsRepository.$saveAudit({ + version: auditSummary.version || 0, hash: blockHash, height: auditSummary.height, time: auditSummary.timestamp || auditSummary.time, + unseenTxs: auditSummary.unseenTxs || [], missingTxs: auditSummary.missingTxs || [], addedTxs: auditSummary.addedTxs || [], prioritizedTxs: auditSummary.prioritizedTxs || [], diff --git a/backend/src/replication/StatisticsReplication.ts b/backend/src/replication/StatisticsReplication.ts new file mode 100644 index 000000000..49259b458 --- /dev/null +++ b/backend/src/replication/StatisticsReplication.ts @@ -0,0 +1,237 @@ +import DB from '../database'; +import logger from '../logger'; +import { $sync } from './replicator'; +import config from '../config'; +import { Common } from '../api/common'; +import statistics from '../api/statistics/statistics-api'; + +interface MissingStatistics { + '24h': Set; + '1w': Set; + '1m': Set; + '3m': Set; + '6m': Set; + '2y': Set; + 'all': Set; +} + +const steps = { + '24h': 60, + '1w': 300, + '1m': 1800, + '3m': 7200, + '6m': 10800, + '2y': 28800, + 'all': 43200, +}; + +/** + * Syncs missing statistics data from trusted servers + */ +class StatisticsReplication { + inProgress: boolean = false; + + public async $sync(): Promise { + if (!config.REPLICATION.ENABLED || !config.REPLICATION.STATISTICS || !config.STATISTICS.ENABLED) { + // replication not enabled, or statistics not enabled + return; + } + if (this.inProgress) { + logger.info(`StatisticsReplication sync already in progress`, 'Replication'); + return; + } + this.inProgress = true; + + const missingStatistics = await this.$getMissingStatistics(); + const missingIntervals = Object.keys(missingStatistics).filter(key => missingStatistics[key].size > 0); + const totalMissing = missingIntervals.reduce((total, key) => total + missingStatistics[key].size, 0); + + if (totalMissing === 0) { + this.inProgress = false; + logger.info(`Statistics table is complete, no replication needed`, 'Replication'); + return; + } + + for (const interval of missingIntervals) { + logger.debug(`Missing ${missingStatistics[interval].size} statistics rows in '${interval}' timespan`, 'Replication'); + } + logger.debug(`Fetching ${missingIntervals.join(', ')} statistics endpoints from trusted servers to fill ${totalMissing} rows missing in statistics`, 'Replication'); + + let totalSynced = 0; + let totalMissed = 0; + + for (const interval of missingIntervals) { + const results = await this.$syncStatistics(interval, missingStatistics[interval]); + totalSynced += results.synced; + totalMissed += results.missed; + + logger.info(`Found ${totalSynced} / ${totalSynced + totalMissed} of ${totalMissing} missing statistics rows`, 'Replication'); + await Common.sleep$(3000); + } + + logger.debug(`Synced ${totalSynced} statistics rows, ${totalMissed} still missing`, 'Replication'); + + this.inProgress = false; + } + + private async $syncStatistics(interval: string, missingTimes: Set): Promise { + + let success = false; + let synced = 0; + let missed = new Set(missingTimes); + const syncResult = await $sync(`/api/v1/statistics/${interval}`); + if (syncResult && syncResult.data?.length) { + success = true; + logger.info(`Fetched /api/v1/statistics/${interval} from ${syncResult.server}`); + + for (const stat of syncResult.data) { + const time = this.roundToNearestStep(stat.added, steps[interval]); + if (missingTimes.has(time)) { + try { + await statistics.$create(statistics.mapOptimizedStatisticToStatistic([stat])[0], true); + if (missed.delete(time)) { + synced++; + } + } catch (e: any) { + logger.err(`Failed to insert statistics row at ${stat.added} (${interval}) from ${syncResult.server}. Reason: ` + (e instanceof Error ? e.message : e)); + } + } + } + + } else { + logger.warn(`An error occured when trying to fetch /api/v1/statistics/${interval}`); + } + + return { success, synced, missed: missed.size }; + } + + private async $getMissingStatistics(): Promise { + try { + const now = Math.floor(Date.now() / 1000); + const day = 60 * 60 * 24; + + const startTime = this.getStartTimeFromConfig(); + + const missingStatistics: MissingStatistics = { + '24h': new Set(), + '1w': new Set(), + '1m': new Set(), + '3m': new Set(), + '6m': new Set(), + '2y': new Set(), + 'all': new Set() + }; + + const intervals = [ // [start, end, label ] + [now - day + 600, now - 60, '24h'] , // from 24 hours ago to now = 1 minute granularity + startTime < now - day ? [now - day * 7, now - day, '1w' ] : null, // from 1 week ago to 24 hours ago = 5 minutes granularity + startTime < now - day * 7 ? [now - day * 30, now - day * 7, '1m' ] : null, // from 1 month ago to 1 week ago = 30 minutes granularity + startTime < now - day * 30 ? [now - day * 90, now - day * 30, '3m' ] : null, // from 3 months ago to 1 month ago = 2 hours granularity + startTime < now - day * 90 ? [now - day * 180, now - day * 90, '6m' ] : null, // from 6 months ago to 3 months ago = 3 hours granularity + startTime < now - day * 180 ? [now - day * 365 * 2, now - day * 180, '2y' ] : null, // from 2 years ago to 6 months ago = 8 hours granularity + startTime < now - day * 365 * 2 ? [startTime, now - day * 365 * 2, 'all'] : null, // from start of statistics to 2 years ago = 12 hours granularity + ]; + + for (const interval of intervals) { + if (!interval) { + continue; + } + missingStatistics[interval[2] as string] = await this.$getMissingStatisticsInterval(interval, startTime); + } + + return missingStatistics; + } catch (e: any) { + logger.err(`Cannot fetch missing statistics times from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + private async $getMissingStatisticsInterval(interval: any, startTime: number): Promise> { + try { + const start = interval[0]; + const end = interval[1]; + const step = steps[interval[2]]; + + const [rows]: any[] = await DB.query(` + SELECT UNIX_TIMESTAMP(added) as added + FROM statistics + WHERE added >= FROM_UNIXTIME(?) AND added <= FROM_UNIXTIME(?) + GROUP BY UNIX_TIMESTAMP(added) DIV ${step} ORDER BY statistics.added DESC + `, [start, end]); + + const startingTime = Math.max(startTime, start) - Math.max(startTime, start) % step; + + const timeSteps: number[] = []; + for (let time = startingTime; time < end; time += step) { + timeSteps.push(time); + } + + if (timeSteps.length === 0) { + return new Set(); + } + + const roundedTimesAlreadyHere: number[] = Array.from(new Set(rows.map(row => this.roundToNearestStep(row.added, step)))); + + const missingTimes = timeSteps.filter(time => !roundedTimesAlreadyHere.includes(time)).filter((time, i, arr) => { + // Remove outsiders + if (i === 0) { + return arr[i + 1] === time + step + } else if (i === arr.length - 1) { + return arr[i - 1] === time - step; + } + return (arr[i + 1] === time + step) && (arr[i - 1] === time - step) + }); + + // Don't bother fetching if very few rows are missing + if (missingTimes.length < timeSteps.length * 0.01) { + return new Set(); + } + + return new Set(missingTimes); + } catch (e: any) { + logger.err(`Cannot fetch missing statistics times from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + private roundToNearestStep(time: number, step: number): number { + const remainder = time % step; + if (remainder < step / 2) { + return time - remainder; + } else { + return time + (step - remainder); + } + } + + private getStartTimeFromConfig(): number { + const now = Math.floor(Date.now() / 1000); + const day = 60 * 60 * 24; + + let startTime: number; + if (typeof(config.REPLICATION.STATISTICS_START_TIME) === 'string' && ['24h', '1w', '1m', '3m', '6m', '2y', 'all'].includes(config.REPLICATION.STATISTICS_START_TIME)) { + if (config.REPLICATION.STATISTICS_START_TIME === 'all') { + startTime = 1481932800; + } else if (config.REPLICATION.STATISTICS_START_TIME === '2y') { + startTime = now - day * 365 * 2; + } else if (config.REPLICATION.STATISTICS_START_TIME === '6m') { + startTime = now - day * 180; + } else if (config.REPLICATION.STATISTICS_START_TIME === '3m') { + startTime = now - day * 90; + } else if (config.REPLICATION.STATISTICS_START_TIME === '1m') { + startTime = now - day * 30; + } else if (config.REPLICATION.STATISTICS_START_TIME === '1w') { + startTime = now - day * 7; + } else { + startTime = now - day; + } + } else { + startTime = Math.max(config.REPLICATION.STATISTICS_START_TIME as number || 1481932800, 1481932800); + } + + return startTime; + } + +} + +export default new StatisticsReplication(); + diff --git a/backend/src/repositories/AccelerationRepository.ts b/backend/src/repositories/AccelerationRepository.ts index 4969013c4..4c9896296 100644 --- a/backend/src/repositories/AccelerationRepository.ts +++ b/backend/src/repositories/AccelerationRepository.ts @@ -1,4 +1,4 @@ -import { AccelerationInfo, makeBlockTemplate } from '../api/acceleration/acceleration'; +import { AccelerationInfo } from '../api/acceleration/acceleration'; import { RowDataPacket } from 'mysql2'; import DB from '../database'; import logger from '../logger'; @@ -6,15 +6,17 @@ import { IEsploraApi } from '../api/bitcoin/esplora-api.interface'; import { Common } from '../api/common'; import config from '../config'; import blocks from '../api/blocks'; -import accelerationApi, { Acceleration } from '../api/services/acceleration'; +import accelerationApi, { Acceleration, AccelerationHistory } from '../api/services/acceleration'; import accelerationCosts from '../api/acceleration/acceleration'; import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; import transactionUtils from '../api/transaction-utils'; import { BlockExtended, MempoolTransactionExtended } from '../mempool.interfaces'; +import { makeBlockTemplate } from '../api/mini-miner'; export interface PublicAcceleration { txid: string, height: number, + added: number, pool: { id: number, slug: string, @@ -29,15 +31,20 @@ export interface PublicAcceleration { class AccelerationRepository { private bidBoostV2Activated = 831580; - public async $saveAcceleration(acceleration: AccelerationInfo, block: IEsploraApi.Block, pool_id: number): Promise { + public async $saveAcceleration(acceleration: AccelerationInfo, block: IEsploraApi.Block, pool_id: number, accelerationData: Acceleration[]): Promise { + const accelerationMap: { [txid: string]: Acceleration } = {}; + for (const acc of accelerationData) { + accelerationMap[acc.txid] = acc; + } try { await DB.query(` - INSERT INTO accelerations(txid, added, height, pool, effective_vsize, effective_fee, boost_rate, boost_cost) - VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?) + INSERT INTO accelerations(txid, requested, added, height, pool, effective_vsize, effective_fee, boost_rate, boost_cost) + VALUE (?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE height = ? `, [ acceleration.txSummary.txid, + accelerationMap[acceleration.txSummary.txid].added, block.timestamp, block.height, pool_id, @@ -64,7 +71,7 @@ class AccelerationRepository { } let query = ` - SELECT * FROM accelerations + SELECT *, UNIX_TIMESTAMP(requested) as requested_timestamp, UNIX_TIMESTAMP(added) as block_timestamp FROM accelerations JOIN pools on pools.unique_id = accelerations.pool `; let params: any[] = []; @@ -99,6 +106,7 @@ class AccelerationRepository { return rows.map(row => ({ txid: row.txid, height: row.height, + added: row.requested_timestamp || row.block_timestamp, pool: { id: row.id, slug: row.slug, @@ -184,6 +192,7 @@ class AccelerationRepository { } } + // modifies block transactions public async $indexAccelerationsForBlock(block: BlockExtended, accelerations: Acceleration[], transactions: MempoolTransactionExtended[]): Promise { const blockTxs: { [txid: string]: MempoolTransactionExtended } = {}; for (const tx of transactions) { @@ -202,9 +211,18 @@ class AccelerationRepository { const tx = blockTxs[acc.txid]; const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions); accelerationInfo.cost = Math.max(0, Math.min(acc.feeDelta, accelerationInfo.cost)); - this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id); + this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id, successfulAccelerations); } } + let anyConfirmed = false; + for (const acc of accelerations) { + if (blockTxs[acc.txid]) { + anyConfirmed = true; + } + } + if (anyConfirmed) { + accelerationApi.accelerationConfirmed(); + } const lastSyncedHeight = await this.$getLastSyncedHeight(); // if we've missed any blocks, let the indexer catch up from the last synced height on the next run if (block.height === lastSyncedHeight + 1) { @@ -230,13 +248,15 @@ class AccelerationRepository { logger.debug(`Fetching accelerations between block ${lastSyncedHeight} and ${currentHeight}`); // Fetch accelerations from mempool.space since the last synced block; - const accelerationsByBlock = {}; + const accelerationsByBlock: {[height: number]: AccelerationHistory[]} = {}; const blockHashes = {}; let done = false; let page = 1; let count = 0; try { while (!done) { + // don't DDoS the services backend + Common.sleep$(500 + (Math.random() * 1000)); const accelerations = await accelerationApi.$fetchAccelerationHistory(page); page++; if (!accelerations?.length) { @@ -297,12 +317,16 @@ class AccelerationRepository { const feeStats = Common.calcEffectiveFeeStatistics(template); boostRate = feeStats.medianFee; } + const accelerationSummaries = accelerations.map(acc => ({ + ...acc, + pools: acc.pools, + })) for (const acc of accelerations) { - if (blockTxs[acc.txid]) { + if (blockTxs[acc.txid] && acc.pools.includes(block.extras.pool.id)) { const tx = blockTxs[acc.txid]; const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions); accelerationInfo.cost = Math.max(0, Math.min(acc.feeDelta, accelerationInfo.cost)); - await this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id); + await this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id, accelerationSummaries); } } await this.$setLastSyncedHeight(height); @@ -317,6 +341,26 @@ class AccelerationRepository { logger.debug(`Indexing accelerations completed`); } + + /** + * Delete accelerations from the database above blockHeight + */ + public async $deleteAccelerationsFrom(blockHeight: number): Promise { + logger.info(`Delete newer accelerations from height ${blockHeight} from the database`); + try { + const currentSyncedHeight = await this.$getLastSyncedHeight(); + if (currentSyncedHeight >= blockHeight) { + await DB.query(` + UPDATE state + SET number = ? + WHERE name = 'last_acceleration_block' + `, [blockHeight - 1]); + } + await DB.query(`DELETE FROM accelerations where height >= ${blockHeight}`); + } catch (e) { + logger.err('Cannot delete indexed accelerations. Reason: ' + (e instanceof Error ? e.message : e)); + } + } } export default new AccelerationRepository(); diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index daf1ba52d..3b3f79ce0 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -1,13 +1,24 @@ -import blocks from '../api/blocks'; import DB from '../database'; import logger from '../logger'; -import { BlockAudit, AuditScore } from '../mempool.interfaces'; +import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; +import { BlockAudit, AuditScore, TransactionAudit, TransactionStripped } from '../mempool.interfaces'; + +interface MigrationAudit { + version: number, + height: number, + id: string, + timestamp: number, + prioritizedTxs: string[], + acceleratedTxs: string[], + template: TransactionStripped[], + transactions: TransactionStripped[], +} class BlocksAuditRepositories { public async $saveAudit(audit: BlockAudit): Promise { try { - await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight) - VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), + await DB.query(`INSERT INTO blocks_audits(version, time, height, hash, unseen_txs, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight) + VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.version, audit.time, audit.height, audit.hash, JSON.stringify(audit.unseenTxs), JSON.stringify(audit.missingTxs), JSON.stringify(audit.addedTxs), JSON.stringify(audit.prioritizedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), JSON.stringify(audit.acceleratedTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart @@ -62,24 +73,30 @@ class BlocksAuditRepositories { public async $getBlockAudit(hash: string): Promise { try { const [rows]: any[] = await DB.query( - `SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp, - template, - missing_txs as missingTxs, - added_txs as addedTxs, - prioritized_txs as prioritizedTxs, - fresh_txs as freshTxs, - sigop_txs as sigopTxs, - fullrbf_txs as fullrbfTxs, - accelerated_txs as acceleratedTxs, - match_rate as matchRate, - expected_fees as expectedFees, - expected_weight as expectedWeight + `SELECT + blocks_audits.version, + blocks_audits.height, + blocks_audits.hash as id, + UNIX_TIMESTAMP(blocks_audits.time) as timestamp, + template, + unseen_txs as unseenTxs, + missing_txs as missingTxs, + added_txs as addedTxs, + prioritized_txs as prioritizedTxs, + fresh_txs as freshTxs, + sigop_txs as sigopTxs, + fullrbf_txs as fullrbfTxs, + accelerated_txs as acceleratedTxs, + match_rate as matchRate, + expected_fees as expectedFees, + expected_weight as expectedWeight FROM blocks_audits JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash WHERE blocks_audits.hash = ? `, [hash]); if (rows.length) { + rows[0].unseenTxs = JSON.parse(rows[0].unseenTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].prioritizedTxs = JSON.parse(rows[0].prioritizedTxs); @@ -98,6 +115,42 @@ class BlocksAuditRepositories { } } + public async $getBlockTxAudit(hash: string, txid: string): Promise { + try { + const blockAudit = await this.$getBlockAudit(hash); + + if (blockAudit) { + const isAdded = blockAudit.addedTxs.includes(txid); + const isPrioritized = blockAudit.prioritizedTxs.includes(txid); + const isAccelerated = blockAudit.acceleratedTxs.includes(txid); + const isConflict = blockAudit.fullrbfTxs.includes(txid); + let isExpected = false; + let firstSeen = undefined; + blockAudit.template?.forEach(tx => { + if (tx.txid === txid) { + isExpected = true; + firstSeen = tx.time; + } + }); + const wasSeen = blockAudit.version === 1 ? !blockAudit.unseenTxs.includes(txid) : (isExpected || isPrioritized || isAccelerated); + + return { + seen: wasSeen, + expected: isExpected, + added: isAdded && (blockAudit.version === 0 || !wasSeen), + prioritized: isPrioritized, + conflict: isConflict, + accelerated: isAccelerated, + firstSeen, + }; + } + return null; + } catch (e: any) { + logger.err(`Cannot fetch block transaction audit from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $getBlockAuditScore(hash: string): Promise { try { const [rows]: any[] = await DB.query( @@ -151,6 +204,96 @@ class BlocksAuditRepositories { throw e; } } + + /** + * [INDEXING] Migrate audits from v0 to v1 + */ + public async $migrateAuditsV0toV1(): Promise { + try { + let done = false; + let processed = 0; + let lastHeight; + while (!done) { + const [toMigrate]: MigrationAudit[][] = await DB.query( + `SELECT + blocks_audits.height as height, + blocks_audits.hash as id, + UNIX_TIMESTAMP(blocks_audits.time) as timestamp, + blocks_summaries.transactions as transactions, + blocks_templates.template as template, + blocks_audits.prioritized_txs as prioritizedTxs, + blocks_audits.accelerated_txs as acceleratedTxs + FROM blocks_audits + JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash + JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash + WHERE blocks_audits.version = 0 + AND blocks_summaries.version = 2 + ORDER BY blocks_audits.height DESC + LIMIT 100 + `) as any[]; + + if (toMigrate.length <= 0 || lastHeight === toMigrate[0].height) { + done = true; + break; + } + lastHeight = toMigrate[0].height; + + logger.info(`migrating ${toMigrate.length} audits to version 1`); + + for (const audit of toMigrate) { + // unpack JSON-serialized transaction lists + audit.transactions = JSON.parse((audit.transactions as any as string) || '[]'); + audit.template = JSON.parse((audit.template as any as string) || '[]'); + + // we know transactions in the template, or marked "prioritized" or "accelerated" + // were seen in our mempool before the block was mined. + const isSeen = new Set(); + for (const tx of audit.template) { + isSeen.add(tx.txid); + } + for (const txid of audit.prioritizedTxs) { + isSeen.add(txid); + } + for (const txid of audit.acceleratedTxs) { + isSeen.add(txid); + } + const unseenTxs = audit.transactions.slice(0).map(tx => tx.txid).filter(txid => !isSeen.has(txid)); + + // identify "prioritized" transactions + const prioritizedTxs: string[] = []; + let lastEffectiveRate = 0; + // Iterate over the mined template from bottom to top (excluding the coinbase) + // Transactions should appear in ascending order of mining priority. + for (let i = audit.transactions.length - 1; i > 0; i--) { + const blockTx = audit.transactions[i]; + // If a tx has a lower in-band effective fee rate than the previous tx, + // it must have been prioritized out-of-band (in order to have a higher mining priority) + // so exclude from the analysis. + if ((blockTx.rate || 0) < lastEffectiveRate) { + prioritizedTxs.push(blockTx.txid); + } else { + lastEffectiveRate = blockTx.rate || 0; + } + } + + // Update audit in the database + await DB.query(` + UPDATE blocks_audits SET + version = ?, + unseen_txs = ?, + prioritized_txs = ? + WHERE hash = ? + `, [1, JSON.stringify(unseenTxs), JSON.stringify(prioritizedTxs), audit.id]); + } + + processed += toMigrate.length; + } + + logger.info(`migrated ${processed} audits to version 1`); + } catch (e: any) { + logger.err(`Error while migrating audits from v0 to v1. Will try again later. Reason: ` + (e instanceof Error ? e.message : e)); + } + } } export default new BlocksAuditRepositories(); diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index ca3f8507d..03cef75f6 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -5,7 +5,7 @@ import logger from '../logger'; import { Common } from '../api/common'; import PoolsRepository from './PoolsRepository'; import HashratesRepository from './HashratesRepository'; -import { RowDataPacket, escape } from 'mysql2'; +import { RowDataPacket } from 'mysql2'; import BlocksSummariesRepository from './BlocksSummariesRepository'; import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository'; import bitcoinClient from '../api/bitcoin/bitcoin-client'; @@ -40,6 +40,7 @@ interface DatabaseBlock { avgFeeRate: number; coinbaseRaw: string; coinbaseAddress: string; + coinbaseAddresses: string; coinbaseSignature: string; coinbaseSignatureAscii: string; avgTxSize: number; @@ -82,6 +83,7 @@ const BLOCK_DB_FIELDS = ` blocks.avg_fee_rate AS avgFeeRate, blocks.coinbase_raw AS coinbaseRaw, blocks.coinbase_address AS coinbaseAddress, + blocks.coinbase_addresses AS coinbaseAddresses, blocks.coinbase_signature AS coinbaseSignature, blocks.coinbase_signature_ascii AS coinbaseSignatureAscii, blocks.avg_tx_size AS avgTxSize, @@ -114,7 +116,7 @@ class BlocksRepository { pool_id, fees, fee_span, median_fee, reward, version, bits, nonce, merkle_root, previous_block_hash, avg_fee, avg_fee_rate, - median_timestamp, header, coinbase_address, + median_timestamp, header, coinbase_address, coinbase_addresses, coinbase_signature, utxoset_size, utxoset_change, avg_tx_size, total_inputs, total_outputs, total_input_amt, total_output_amt, fee_percentiles, segwit_total_txs, segwit_total_size, segwit_total_weight, @@ -125,7 +127,7 @@ class BlocksRepository { ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - FROM_UNIXTIME(?), ?, ?, + FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -161,6 +163,7 @@ class BlocksRepository { block.mediantime, block.extras.header, block.extras.coinbaseAddress, + block.extras.coinbaseAddresses ? JSON.stringify(block.extras.coinbaseAddresses) : null, truncatedCoinbaseSignature, block.extras.utxoSetSize, block.extras.utxoSetChange, @@ -529,7 +532,7 @@ class BlocksRepository { return null; } - return await this.formatDbBlockIntoExtendedBlock(rows[0] as DatabaseBlock); + return await this.formatDbBlockIntoExtendedBlock(rows[0] as DatabaseBlock); } catch (e) { logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -663,7 +666,7 @@ class BlocksRepository { /** * Get the historical averaged block fees */ - public async $getHistoricalBlockFees(div: number, interval: string | null): Promise { + public async $getHistoricalBlockFees(div: number, interval: string | null, timespan?: {from: number, to: number}): Promise { try { let query = `SELECT CAST(AVG(blocks.height) as INT) as avgHeight, @@ -677,6 +680,8 @@ class BlocksRepository { if (interval !== null) { query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } else if (timespan) { + query += ` WHERE blockTimestamp BETWEEN FROM_UNIXTIME(${timespan.from}) AND FROM_UNIXTIME(${timespan.to})`; } query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; @@ -920,6 +925,25 @@ class BlocksRepository { } } + /** + * Get all indexed blocks with missing coinbase addresses + */ + public async $getBlocksWithoutCoinbaseAddresses(): Promise { + try { + const [blocks] = await DB.query(` + SELECT height, hash, coinbase_addresses + FROM blocks + WHERE coinbase_addresses IS NULL AND + coinbase_address IS NOT NULL + ORDER BY height DESC + `); + return blocks; + } catch (e) { + logger.err(`Cannot get blocks with missing coinbase addresses. Reason: ` + (e instanceof Error ? e.message : e)); + return []; + } + } + /** * Save indexed median fee to avoid recomputing it later * @@ -958,6 +982,44 @@ class BlocksRepository { } } + /** + * Save coinbase addresses + * + * @param id + * @param addresses + */ + public async $saveCoinbaseAddresses(id: string, addresses: string[]): Promise { + try { + await DB.query(` + UPDATE blocks SET coinbase_addresses = ? + WHERE hash = ?`, + [JSON.stringify(addresses), id] + ); + } catch (e) { + logger.err(`Cannot update block coinbase addresses. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + /** + * Save pool + * + * @param id + * @param poolId + */ + public async $savePool(id: string, poolId: number): Promise { + try { + await DB.query(` + UPDATE blocks SET pool_id = ? + WHERE hash = ?`, + [poolId, id] + ); + } catch (e) { + logger.err(`Cannot update block pool. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + /** * Convert a mysql row block into a BlockExtended. Note that you * must provide the correct field into dbBlk object param @@ -997,6 +1059,7 @@ class BlocksRepository { extras.avgFeeRate = dbBlk.avgFeeRate; extras.coinbaseRaw = dbBlk.coinbaseRaw; extras.coinbaseAddress = dbBlk.coinbaseAddress; + extras.coinbaseAddresses = dbBlk.coinbaseAddresses ? JSON.parse(dbBlk.coinbaseAddresses) : []; extras.coinbaseSignature = dbBlk.coinbaseSignature; extras.coinbaseSignatureAscii = dbBlk.coinbaseSignatureAscii; extras.avgTxSize = dbBlk.avgTxSize; @@ -1043,7 +1106,7 @@ class BlocksRepository { let summaryVersion = 0; if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx)); - summary = blocks.summarizeBlockTransactions(dbBlk.id, txs); + summary = blocks.summarizeBlockTransactions(dbBlk.id, dbBlk.height, txs); summaryVersion = 1; } else { // Call Core RPC diff --git a/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts index 63ad5ddf2..0268424f2 100644 --- a/backend/src/repositories/BlocksSummariesRepository.ts +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -114,6 +114,43 @@ class BlocksSummariesRepository { return []; } + public async $getSummariesBelowVersion(version: number): Promise<{ height: number, id: string, version: number }[]> { + try { + const [rows]: any[] = await DB.query(` + SELECT + height, + id, + version + FROM blocks_summaries + WHERE version < ? + ORDER BY height DESC;`, [version]); + return rows; + } catch (e) { + logger.err(`Cannot get block summaries below version. Reason: ` + (e instanceof Error ? e.message : e)); + } + + return []; + } + + public async $getTemplatesBelowVersion(version: number): Promise<{ height: number, id: string, version: number }[]> { + try { + const [rows]: any[] = await DB.query(` + SELECT + blocks_summaries.height as height, + blocks_templates.id as id, + blocks_templates.version as version + FROM blocks_templates + JOIN blocks_summaries ON blocks_templates.id = blocks_summaries.id + WHERE blocks_templates.version < ? + ORDER BY height DESC;`, [version]); + return rows; + } catch (e) { + logger.err(`Cannot get block summaries below version. Reason: ` + (e instanceof Error ? e.message : e)); + } + + return []; + } + /** * Get the fee percentiles if the block has already been indexed, [] otherwise * diff --git a/backend/src/repositories/CpfpRepository.ts b/backend/src/repositories/CpfpRepository.ts index b33ff1e4a..0242188df 100644 --- a/backend/src/repositories/CpfpRepository.ts +++ b/backend/src/repositories/CpfpRepository.ts @@ -91,6 +91,26 @@ class CpfpRepository { return; } + public async $getClustersAt(height: number): Promise { + const [clusterRows]: any = await DB.query( + ` + SELECT * + FROM compact_cpfp_clusters + WHERE height = ? + `, + [height] + ); + return clusterRows.map(cluster => { + if (cluster?.txs) { + cluster.effectiveFeePerVsize = cluster.fee_rate; + cluster.txs = this.unpack(cluster.txs); + return cluster; + } else { + return null; + } + }).filter(cluster => cluster !== null); + } + public async $deleteClustersFrom(height: number): Promise { logger.info(`Delete newer cpfp clusters from height ${height} from the database`); try { @@ -122,6 +142,37 @@ class CpfpRepository { } } + public async $deleteClustersAt(height: number): Promise { + logger.info(`Delete cpfp clusters at height ${height} from the database`); + try { + const [rows] = await DB.query( + ` + SELECT txs, height, root from compact_cpfp_clusters + WHERE height = ? + `, + [height] + ) as RowDataPacket[][]; + if (rows?.length) { + for (const clusterToDelete of rows) { + const txs = this.unpack(clusterToDelete?.txs); + for (const tx of txs) { + await transactionRepository.$removeTransaction(tx.txid); + } + } + } + await DB.query( + ` + DELETE from compact_cpfp_clusters + WHERE height = ? + `, + [height] + ); + } catch (e: any) { + logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + // insert a dummy row to mark that we've indexed as far as this block public async $insertProgressMarker(height: number): Promise { try { @@ -190,6 +241,32 @@ class CpfpRepository { return []; } } + + // returns `true` if two sets of CPFP clusters are deeply identical + public compareClusters(clustersA: CpfpCluster[], clustersB: CpfpCluster[]): boolean { + if (clustersA.length !== clustersB.length) { + return false; + } + + clustersA = clustersA.sort((a,b) => a.root.localeCompare(b.root)); + clustersB = clustersB.sort((a,b) => a.root.localeCompare(b.root)); + + for (let i = 0; i < clustersA.length; i++) { + if (clustersA[i].root !== clustersB[i].root) { + return false; + } + if (clustersA[i].txs.length !== clustersB[i].txs.length) { + return false; + } + for (let j = 0; j < clustersA[i].txs.length; j++) { + if (clustersA[i].txs[j].txid !== clustersB[i].txs[j].txid) { + return false; + } + } + } + + return true; + } } export default new CpfpRepository(); \ No newline at end of file diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts index 6e8173c21..a3a3265c6 100644 --- a/backend/src/tasks/pools-updater.ts +++ b/backend/src/tasks/pools-updater.ts @@ -50,10 +50,10 @@ class PoolsUpdater { // See backend README for more details about the mining pools update process if (this.currentSha !== null && // If we don't have any mining pool, download it at least once - config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING !== true && // Automatic pools update is disabled + config.MEMPOOL.AUTOMATIC_POOLS_UPDATE !== true && // Automatic pools update is disabled !process.env.npm_config_update_pools // We're not manually updating mining pool ) { - logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_BLOCK_REINDEXING is disabled`); + logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_POOLS_UPDATE is disabled`); logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`); return; } diff --git a/backend/src/tasks/price-feeds/free-currency-api.ts b/backend/src/tasks/price-feeds/free-currency-api.ts index 8d6175b95..48e511aa8 100644 --- a/backend/src/tasks/price-feeds/free-currency-api.ts +++ b/backend/src/tasks/price-feeds/free-currency-api.ts @@ -1,3 +1,4 @@ +import config from '../../config'; import { query } from '../../utils/axios-query'; import { ConversionFeed, ConversionRates } from '../price-updater'; @@ -37,15 +38,26 @@ const emptyRates = { ZAR: -1, }; -class FreeCurrencyApi implements ConversionFeed { - private API_KEY: string; - - constructor(apiKey: string) { - this.API_KEY = apiKey; +type PaidCurrencyData = { + [key: string]: { + code: string; + value: number; } +}; + +type FreeCurrencyData = { + [key: string]: number; +}; + +class FreeCurrencyApi implements ConversionFeed { + private API_KEY = config.FIAT_PRICE.API_KEY; + private PAID = config.FIAT_PRICE.PAID; + private API_URL_PREFIX: string = this.PAID ? `https://api.currencyapi.com/v3/` : `https://api.freecurrencyapi.com/v1/`; + + constructor() { } public async $getQuota(): Promise { - const response = await query(`https://api.freecurrencyapi.com/v1/status?apikey=${this.API_KEY}`); + const response = await query(`${this.API_URL_PREFIX}status?apikey=${this.API_KEY}`); if (response && response['quotas']) { return response['quotas']; } @@ -53,21 +65,36 @@ class FreeCurrencyApi implements ConversionFeed { } public async $fetchLatestConversionRates(): Promise { - const response = await query(`https://api.freecurrencyapi.com/v1/latest?apikey=${this.API_KEY}`); + const response = await query(`${this.API_URL_PREFIX}latest?apikey=${this.API_KEY}`); if (response && response['data']) { + if (this.PAID) { + response['data'] = this.convertData(response['data']); + } return response['data']; } return emptyRates; } public async $fetchConversionRates(date: string): Promise { - const response = await query(`https://api.freecurrencyapi.com/v1/historical?date=${date}&apikey=${this.API_KEY}`); - if (response && response['data'] && response['data'][date]) { + const response = await query(`${this.API_URL_PREFIX}historical?date=${date}&apikey=${this.API_KEY}`, true); + if (response && response['data'] && (response['data'][date] || this.PAID)) { + if (this.PAID) { + response['data'] = this.convertData(response['data']); + response['data'][response['meta'].last_updated_at.substr(0, 10)] = response['data']; + } return response['data'][date]; } return emptyRates; } + private convertData(data: PaidCurrencyData): FreeCurrencyData { + const simplifiedData: FreeCurrencyData = {}; + for (const key in data) { + simplifiedData[key] = data[key].value; + } + return simplifiedData; + } + } export default FreeCurrencyApi; diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index 7ed4cb178..467669a6f 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -59,7 +59,7 @@ class PriceUpdater { private currencyConversionFeed: ConversionFeed | undefined; private newCurrencies: string[] = ['BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR']; private lastTimeConversionsRatesFetched: number = 0; - private latestConversionsRatesFromFeed: ConversionRates = {}; + private latestConversionsRatesFromFeed: ConversionRates = { USD: -1 }; private ratesChangedCallback: ((rates: ApiPrice) => void) | undefined; constructor() { @@ -71,7 +71,7 @@ class PriceUpdater { this.feeds.push(new BitfinexApi()); this.feeds.push(new GeminiApi()); - this.currencyConversionFeed = new FreeCurrencyApi(config.FIAT_PRICE.API_KEY); + this.currencyConversionFeed = new FreeCurrencyApi(); this.setCyclePosition(); } @@ -157,9 +157,9 @@ class PriceUpdater { try { this.latestConversionsRatesFromFeed = await this.currencyConversionFeed.$fetchLatestConversionRates(); this.lastTimeConversionsRatesFetched = Math.round(new Date().getTime() / 1000); - logger.debug(`Fetched currencies conversion rates from external API: ${JSON.stringify(this.latestConversionsRatesFromFeed)}`); + logger.debug(`Fetched currencies conversion rates from conversions API: ${JSON.stringify(this.latestConversionsRatesFromFeed)}`); } catch (e) { - logger.err(`Cannot fetch conversion rates from the API. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Cannot fetch conversion rates from conversions API. Reason: ${(e instanceof Error ? e.message : e)}`); } } @@ -408,17 +408,17 @@ class PriceUpdater { try { const remainingQuota = await this.currencyConversionFeed?.$getQuota(); if (remainingQuota['month']['remaining'] < 500) { // We need some calls left for the daily updates - logger.debug(`Not enough currency API credit to insert missing prices in ${priceTimesToFill.length} rows (${remainingQuota['month']['remaining']} calls left).`, logger.tags.mining); + logger.debug(`Not enough conversions API credit to insert missing prices in ${priceTimesToFill.length} rows (${remainingQuota['month']['remaining']} calls left).`, logger.tags.mining); this.additionalCurrenciesHistoryInserted = true; // Do not try again until next day return; } } catch (e) { - logger.err(`Cannot fetch currency API credit, insertion of missing prices aborted. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Cannot fetch conversions API credit, insertion of missing prices aborted. Reason: ${(e instanceof Error ? e.message : e)}`); return; } this.additionalCurrenciesHistoryRunning = true; - logger.debug(`Fetching missing conversion rates from external API to fill ${priceTimesToFill.length} rows`, logger.tags.mining); + logger.debug(`Inserting missing historical conversion rates using conversions API to fill ${priceTimesToFill.length} rows`, logger.tags.mining); let conversionRates: { [timestamp: number]: ConversionRates } = {}; let totalInserted = 0; @@ -430,10 +430,23 @@ class PriceUpdater { const month = new Date(priceTime.time * 1000).getMonth(); const yearMonthTimestamp = new Date(year, month, 1).getTime() / 1000; if (conversionRates[yearMonthTimestamp] === undefined) { - conversionRates[yearMonthTimestamp] = await this.currencyConversionFeed?.$fetchConversionRates(`${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01`) || { USD: -1 }; - if (conversionRates[yearMonthTimestamp]['USD'] < 0) { - logger.err(`Cannot fetch conversion rates from the API for ${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01. Aborting insertion of missing prices.`, logger.tags.mining); - this.lastFailedHistoricalRun = Math.round(new Date().getTime() / 1000); + try { + if (year === new Date().getFullYear() && month === new Date().getMonth()) { // For rows in the current month, we use the latest conversion rates + conversionRates[yearMonthTimestamp] = this.latestConversionsRatesFromFeed; + } else { + conversionRates[yearMonthTimestamp] = await this.currencyConversionFeed?.$fetchConversionRates(`${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-15`) || { USD: -1 }; + } + + if (conversionRates[yearMonthTimestamp]['USD'] < 0) { + throw new Error('Incorrect USD conversion rate'); + } + } catch (e) { + if ((e instanceof Error ? e.message : '').includes('429')) { // Continue 60 seconds later if and only if error is 429 + this.lastFailedHistoricalRun = Math.round(new Date().getTime() / 1000); + logger.info(`Got a 429 error from conversions API. This is expected to happen a few times during the initial historical price insertion, process will resume in 60 seconds.`, logger.tags.mining); + } else { + logger.err(`Cannot fetch conversion rates from conversions API for ${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01, trying again next day. Error: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining); + } break; } } diff --git a/backend/src/utils/axios-query.ts b/backend/src/utils/axios-query.ts index 0a155fd55..e641d3ce9 100644 --- a/backend/src/utils/axios-query.ts +++ b/backend/src/utils/axios-query.ts @@ -5,7 +5,7 @@ import config from '../config'; import logger from '../logger'; import * as https from 'https'; -export async function query(path): Promise { +export async function query(path, throwOnFail: boolean = false): Promise { type axiosOptions = { headers: { 'User-Agent': string @@ -21,6 +21,7 @@ export async function query(path): Promise { timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000 }; let retry = 0; + let lastError: any = null; while (retry < config.MEMPOOL.EXTERNAL_MAX_RETRY) { try { @@ -50,6 +51,7 @@ export async function query(path): Promise { } return data.data; } catch (e) { + lastError = e; logger.warn(`Could not connect to ${path} (Attempt ${retry + 1}/${config.MEMPOOL.EXTERNAL_MAX_RETRY}). Reason: ` + (e instanceof Error ? e.message : e)); retry++; } @@ -59,5 +61,10 @@ export async function query(path): Promise { } logger.err(`Could not connect to ${path}. All ${config.MEMPOOL.EXTERNAL_MAX_RETRY} attempts failed`); + + if (throwOnFail && lastError) { + throw lastError; + } + return undefined; } diff --git a/backend/src/utils/bitcoin-script.ts b/backend/src/utils/bitcoin-script.ts index 3414e8269..8f551aa23 100644 --- a/backend/src/utils/bitcoin-script.ts +++ b/backend/src/utils/bitcoin-script.ts @@ -158,7 +158,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb if (!opN) { return; } - if (!opN.startsWith('OP_PUSHNUM_')) { + if (opN !== 'OP_0' && !opN.startsWith('OP_PUSHNUM_')) { return; } const n = parseInt(opN.match(/[0-9]+/)?.[0] || '', 10); @@ -178,7 +178,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb if (!opM) { return; } - if (!opM.startsWith('OP_PUSHNUM_')) { + if (opM !== 'OP_0' && !opM.startsWith('OP_PUSHNUM_')) { return; } const m = parseInt(opM.match(/[0-9]+/)?.[0] || '', 10); diff --git a/contributors/bitcoinmechanic.txt b/contributors/bitcoinmechanic.txt new file mode 100644 index 000000000..b2574b2fa --- /dev/null +++ b/contributors/bitcoinmechanic.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: bitcoinmechanic diff --git a/contributors/daweilv.txt b/contributors/daweilv.txt new file mode 100644 index 000000000..2abb9f73b --- /dev/null +++ b/contributors/daweilv.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of April 7, 2024. + +Signed: daweilv diff --git a/contributors/hans-crypto.txt b/contributors/hans-crypto.txt new file mode 100644 index 000000000..d43651294 --- /dev/null +++ b/contributors/hans-crypto.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of May 21, 2024. + +Signed: hans-crypto diff --git a/contributors/henrialb.txt b/contributors/henrialb.txt new file mode 100644 index 000000000..7902ebefb --- /dev/null +++ b/contributors/henrialb.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of April 12, 2024. + +Signed: henrialb diff --git a/contributors/jlopp.txt b/contributors/jlopp.txt new file mode 100644 index 000000000..aaebe7335 --- /dev/null +++ b/contributors/jlopp.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 12, 2024. + +Signed: jlopp diff --git a/contributors/mackalex.txt b/contributors/mackalex.txt new file mode 100644 index 000000000..667e31a22 --- /dev/null +++ b/contributors/mackalex.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of June 18th, 2024. + +Signed: mackalex diff --git a/contributors/svrgnty.txt b/contributors/svrgnty.txt new file mode 100644 index 000000000..e25fd5690 --- /dev/null +++ b/contributors/svrgnty.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 9, 2024. + +Signed: svrgnty \ No newline at end of file diff --git a/docker/README.md b/docker/README.md index 32ed6fcde..ce1548e91 100644 --- a/docker/README.md +++ b/docker/README.md @@ -106,7 +106,7 @@ Below we list all settings from `mempool-config.json` and the corresponding over "EXTERNAL_ASSETS": [], "STDOUT_LOG_MIN_PRIORITY": "info", "INDEXING_BLOCKS_AMOUNT": false, - "AUTOMATIC_BLOCK_REINDEXING": false, + "AUTOMATIC_POOLS_UPDATE": false, "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", "CPFP_INDEXING": false, @@ -137,7 +137,7 @@ Corresponding `docker-compose.yml` overrides: MEMPOOL_EXTERNAL_ASSETS: "" MEMPOOL_STDOUT_LOG_MIN_PRIORITY: "" MEMPOOL_INDEXING_BLOCKS_AMOUNT: "" - MEMPOOL_AUTOMATIC_BLOCK_REINDEXING: "" + MEMPOOL_AUTOMATIC_POOLS_UPDATE: "" MEMPOOL_POOLS_JSON_URL: "" MEMPOOL_POOLS_JSON_TREE_URL: "" MEMPOOL_CPFP_INDEXING: "" diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index d8eada208..60d663f20 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.12.0-buster-slim AS builder +FROM node:20.15.0-buster-slim AS builder ARG commitHash ENV MEMPOOL_COMMIT_HASH=${commitHash} @@ -24,7 +24,7 @@ RUN npm install --omit=dev --omit=optional WORKDIR /build RUN npm run package -FROM node:20.12.0-buster-slim +FROM node:20.15.0-buster-slim WORKDIR /backend diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index ea3fb56a1..79cd14644 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -6,6 +6,7 @@ "OFFICIAL": __MEMPOOL_OFFICIAL__, "HTTP_PORT": __MEMPOOL_HTTP_PORT__, "SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__, + "UNIX_SOCKET_PATH": "__MEMPOOL_UNIX_SOCKET_PATH__", "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", "POLL_RATE_MS": __MEMPOOL_POLL_RATE_MS__, "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", @@ -24,7 +25,7 @@ "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__, "BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__, "GOGGLES_INDEXING": __MEMPOOL_GOGGLES_INDEXING__, - "AUTOMATIC_BLOCK_REINDEXING": __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__, + "AUTOMATIC_POOLS_UPDATE": __MEMPOOL_AUTOMATIC_POOLS_UPDATE__, "AUDIT": __MEMPOOL_AUDIT__, "RUST_GBT": __MEMPOOL_RUST_GBT__, "LIMIT_GBT": __MEMPOOL_LIMIT_GBT__, @@ -59,7 +60,8 @@ "RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__, "REQUEST_TIMEOUT": __ESPLORA_REQUEST_TIMEOUT__, "FALLBACK_TIMEOUT": __ESPLORA_FALLBACK_TIMEOUT__, - "FALLBACK": __ESPLORA_FALLBACK__ + "FALLBACK": __ESPLORA_FALLBACK__, + "MAX_BEHIND_TIP": __ESPLORA_MAX_BEHIND_TIP__ }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", @@ -136,6 +138,8 @@ "ENABLED": __REPLICATION_ENABLED__, "AUDIT": __REPLICATION_AUDIT__, "AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__, + "STATISTICS": __REPLICATION_STATISTICS__, + "STATISTICS_START_TIME": __REPLICATION_STATISTICS_START_TIME__, "SERVERS": __REPLICATION_SERVERS__ }, "MEMPOOL_SERVICES": { @@ -149,6 +153,7 @@ }, "FIAT_PRICE": { "ENABLED": __FIAT_PRICE_ENABLED__, + "PAID": __FIAT_PRICE_PAID__, "API_KEY": "__FIAT_PRICE_API_KEY__" } } diff --git a/docker/backend/start.sh b/docker/backend/start.sh index 611cba387..8033531ef 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -7,6 +7,7 @@ __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=true} __MEMPOOL_OFFICIAL__=${MEMPOOL_OFFICIAL:=false} __MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999} __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} +__MEMPOOL_UNIX_SOCKET_PATH__=${MEMPOOL_UNIX_SOCKET_PATH:=""} __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} __MEMPOOL_POLL_RATE_MS__=${MEMPOOL_POLL_RATE_MS:=2000} __MEMPOOL_CACHE_DIR__=${MEMPOOL_CACHE_DIR:=./cache} @@ -25,7 +26,7 @@ __MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1} __MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0} __MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool} __MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info} -__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__=${MEMPOOL_AUTOMATIC_BLOCK_REINDEXING:=false} +__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false} __MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json} __MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master} __MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false} @@ -61,6 +62,7 @@ __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} __ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000} __ESPLORA_FALLBACK_TIMEOUT__=${ESPLORA_FALLBACK_TIMEOUT:=5000} __ESPLORA_FALLBACK__=${ESPLORA_FALLBACK:=[]} +__ESPLORA_MAX_BEHIND_TIP__=${ESPLORA_MAX_BEHIND_TIP:=2} # SECOND_CORE_RPC __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} @@ -137,19 +139,22 @@ __MAXMIND_GEOIP2_ISP__=${MAXMIND_GEOIP2_ISP:=""} __REPLICATION_ENABLED__=${REPLICATION_ENABLED:=false} __REPLICATION_AUDIT__=${REPLICATION_AUDIT:=false} __REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000} +__REPLICATION_STATISTICS__=${REPLICATION_STATISTICS:=false} +__REPLICATION_STATISTICS_START_TIME__=${REPLICATION_STATISTICS_START_TIME:=1481932800} __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} # MEMPOOL_SERVICES -__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:=""} +__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"} __MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false} # REDIS __REDIS_ENABLED__=${REDIS_ENABLED:=false} -__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=true} +__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""} __REDIS_BATCH_QUERY_BASE_SIZE__=${REDIS_BATCH_QUERY_BASE_SIZE:=5000} # FIAT_PRICE __FIAT_PRICE_ENABLED__=${FIAT_PRICE_ENABLED:=true} +__FIAT_PRICE_PAID__=${FIAT_PRICE_PAID:=false} __FIAT_PRICE_API_KEY__=${FIAT_PRICE_API_KEY:=""} mkdir -p "${__MEMPOOL_CACHE_DIR__}" @@ -160,6 +165,7 @@ sed -i "s!__MEMPOOL_ENABLED__!${__MEMPOOL_ENABLED__}!g" mempool-config.json sed -i "s!__MEMPOOL_OFFICIAL__!${__MEMPOOL_OFFICIAL__}!g" mempool-config.json sed -i "s!__MEMPOOL_HTTP_PORT__!${__MEMPOOL_HTTP_PORT__}!g" mempool-config.json sed -i "s!__MEMPOOL_SPAWN_CLUSTER_PROCS__!${__MEMPOOL_SPAWN_CLUSTER_PROCS__}!g" mempool-config.json +sed -i "s!__MEMPOOL_UNIX_SOCKET_PATH__!${__MEMPOOL_UNIX_SOCKET_PATH__}!g" mempool-config.json sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json sed -i "s!__MEMPOOL_POLL_RATE_MS__!${__MEMPOOL_POLL_RATE_MS__}!g" mempool-config.json sed -i "s!__MEMPOOL_CACHE_DIR__!${__MEMPOOL_CACHE_DIR__}!g" mempool-config.json @@ -178,7 +184,7 @@ sed -i "s!__MEMPOOL_EXTERNAL_MAX_RETRY__!${__MEMPOOL_EXTERNAL_MAX_RETRY__}!g" me sed -i "s!__MEMPOOL_EXTERNAL_RETRY_INTERVAL__!${__MEMPOOL_EXTERNAL_RETRY_INTERVAL__}!g" mempool-config.json sed -i "s!__MEMPOOL_USER_AGENT__!${__MEMPOOL_USER_AGENT__}!g" mempool-config.json sed -i "s!__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__!${__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__}!g" mempool-config.json -sed -i "s!__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__!${__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__}!g" mempool-config.json +sed -i "s!__MEMPOOL_AUTOMATIC_POOLS_UPDATE__!${__MEMPOOL_AUTOMATIC_POOLS_UPDATE__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json @@ -211,6 +217,7 @@ sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTE sed -i "s!__ESPLORA_REQUEST_TIMEOUT__!${__ESPLORA_REQUEST_TIMEOUT__}!g" mempool-config.json sed -i "s!__ESPLORA_FALLBACK_TIMEOUT__!${__ESPLORA_FALLBACK_TIMEOUT__}!g" mempool-config.json sed -i "s!__ESPLORA_FALLBACK__!${__ESPLORA_FALLBACK__}!g" mempool-config.json +sed -i "s!__ESPLORA_MAX_BEHIND_TIP__!${__ESPLORA_MAX_BEHIND_TIP__}!g" mempool-config.json sed -i "s!__SECOND_CORE_RPC_HOST__!${__SECOND_CORE_RPC_HOST__}!g" mempool-config.json sed -i "s!__SECOND_CORE_RPC_PORT__!${__SECOND_CORE_RPC_PORT__}!g" mempool-config.json @@ -281,6 +288,8 @@ sed -i "s!__MAXMIND_GEOIP2_ISP__!${__MAXMIND_GEOIP2_ISP__}!g" mempool-config.jso sed -i "s!__REPLICATION_ENABLED__!${__REPLICATION_ENABLED__}!g" mempool-config.json sed -i "s!__REPLICATION_AUDIT__!${__REPLICATION_AUDIT__}!g" mempool-config.json sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json +sed -i "s!__REPLICATION_STATISTICS__!${__REPLICATION_STATISTICS__}!g" mempool-config.json +sed -i "s!__REPLICATION_STATISTICS_START_TIME__!${__REPLICATION_STATISTICS_START_TIME__}!g" mempool-config.json sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json # MEMPOOL_SERVICES @@ -294,6 +303,7 @@ sed -i "s!__REDIS_BATCH_QUERY_BASE_SIZE__!${__REDIS_BATCH_QUERY_BASE_SIZE__}!g" # FIAT_PRICE sed -i "s!__FIAT_PRICE_ENABLED__!${__FIAT_PRICE_ENABLED__}!g" mempool-config.json +sed -i "s!__FIAT_PRICE_PAID__!${__FIAT_PRICE_PAID__}!g" mempool-config.json sed -i "s!__FIAT_PRICE_API_KEY__!${__FIAT_PRICE_API_KEY__}!g" mempool-config.json node /backend/package/index.js diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 3a63107bf..8374ebe49 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.12.0-buster-slim AS builder +FROM node:20.15.0-buster-slim AS builder ARG commitHash ENV DOCKER_COMMIT_HASH=${commitHash} @@ -13,7 +13,7 @@ RUN npm install --omit=dev --omit=optional RUN npm run build -FROM nginx:1.25.4-alpine +FROM nginx:1.27.0-alpine WORKDIR /patch diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index a57c599b4..2086188c9 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -16,7 +16,9 @@ fi # Runtime overrides - read env vars defined in docker compose +__MAINNET_ENABLED__=${MAINNET_ENABLED:=true} __TESTNET_ENABLED__=${TESTNET_ENABLED:=false} +__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false} __SIGNET_ENABLED__=${SIGNET_ENABLED:=false} __LIQUID_ENABLED__=${LIQUID_ENABLED:=false} __LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false} @@ -28,6 +30,7 @@ __NGINX_PORT__=${NGINX_PORT:=8999} __BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000} __MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8} __BASE_MODULE__=${BASE_MODULE:=mempool} +__ROOT_NETWORK__=${ROOT_NETWORK:=} __MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space} __LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network} __MINING_DASHBOARD__=${MINING_DASHBOARD:=true} @@ -37,12 +40,16 @@ __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} __ACCELERATOR__=${ACCELERATOR:=false} +__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true} +__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services} __PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} __ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false} # Export as environment variables to be used by envsubst +export __MAINNET_ENABLED__ export __TESTNET_ENABLED__ +export __TESTNET4_ENABLED__ export __SIGNET_ENABLED__ export __LIQUID_ENABLED__ export __LIQUID_TESTNET_ENABLED__ @@ -54,6 +61,7 @@ export __NGINX_PORT__ export __BLOCK_WEIGHT_UNITS__ export __MEMPOOL_BLOCKS_AMOUNT__ export __BASE_MODULE__ +export __ROOT_NETWORK__ export __MEMPOOL_WEBSITE_URL__ export __LIQUID_WEBSITE_URL__ export __MINING_DASHBOARD__ @@ -63,6 +71,8 @@ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ export __ACCELERATOR__ +export __ACCELERATOR_BUTTON__ +export __SERVICES_API__ export __PUBLIC_ACCELERATIONS__ export __HISTORICAL_PRICE__ export __ADDITIONAL_CURRENCIES__ diff --git a/frontend/.eslintrc b/frontend/.eslintrc index e2652c6c8..5162bf474 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -34,6 +34,8 @@ "prefer-rest-params": 1, "quotes": [1, "single", { "allowTemplateLiterals": true }], "semi": 1, - "eqeqeq": 1 + "curly": [1, "all"], + "eqeqeq": 1, + "no-trailing-spaces": 1 } } diff --git a/frontend/.gitignore b/frontend/.gitignore index d2a765dda..c10a00946 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -63,6 +63,7 @@ src/resources/pools.json src/resources/mining-pools/* src/resources/**/*.mp4 src/resources/**/*.vtt +src/resources/customize.js # environment config mempool-frontend-config.json diff --git a/frontend/README.md b/frontend/README.md index 069f1d5f0..fb2a5e291 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -33,7 +33,7 @@ $ npm run config:defaults:liquid ### 3. Run the Frontend -_Make sure to use Node.js 16.10 and npm 7._ +_Make sure to use Node.js 20.x and npm 9.x or newer._ Install project dependencies and run the frontend server: @@ -70,7 +70,7 @@ Set up the [Mempool backend](../backend/) first, if you haven't already. ### 1. Build the Frontend -_Make sure to use Node.js 16.10 and npm 7._ +_Make sure to use Node.js 20.x and npm 9.x or newer._ Build the frontend: diff --git a/frontend/angular.json b/frontend/angular.json index f55c59ae9..3aa1cb6a8 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -54,6 +54,10 @@ "translation": "src/locale/messages.fr.xlf", "baseHref": "/fr/" }, + "hr": { + "translation": "src/locale/messages.hr.xlf", + "baseHref": "/hr/" + }, "ja": { "translation": "src/locale/messages.ja.xlf", "baseHref": "/ja/" @@ -166,10 +170,26 @@ "src/resources", "src/robots.txt", "src/config.js", + "src/customize.js", "src/config.template.js" ], "styles": [ "src/styles.scss", + { + "input": "src/theme-contrast.scss", + "bundleName": "contrast", + "inject": false + }, + { + "input": "src/theme-wiz.scss", + "bundleName": "wiz", + "inject": false + }, + { + "input": "src/theme-bukele.scss", + "bundleName": "bukele", + "inject": false + }, "node_modules/@fortawesome/fontawesome-svg-core/styles.css" ], "vendorChunk": true, diff --git a/frontend/custom-sv-config.json b/frontend/custom-sv-config.json new file mode 100644 index 000000000..dee3dab18 --- /dev/null +++ b/frontend/custom-sv-config.json @@ -0,0 +1,52 @@ +{ + "theme": "bukele", + "enterprise": "onbtc", + "branding": { + "name": "onbtc", + "title": "Bitcoin Office", + "site_id": 19, + "header_img": "/resources/onbtclogo.svg", + "footer_img": "/resources/onbtclogo.svg", + "rounded_corner": true + }, + "dashboard": { + "widgets": [ + { + "component": "fees", + "mobileOrder": 4 + }, + { + "component": "balance", + "mobileOrder": 1, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + }, + { + "component": "twitter", + "mobileOrder": 5, + "props": { + "handle": "nayibbukele" + } + }, + { + "component": "address", + "mobileOrder": 2, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo", + "period": "1m" + } + }, + { + "component": "blocks" + }, + { + "component": "addressTransactions", + "mobileOrder": 3, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + } + ] + } +} \ No newline at end of file diff --git a/frontend/cypress/e2e/liquid/liquid.spec.ts b/frontend/cypress/e2e/liquid/liquid.spec.ts index 8548059bb..c7d2a92ee 100644 --- a/frontend/cypress/e2e/liquid/liquid.spec.ts +++ b/frontend/cypress/e2e/liquid/liquid.spec.ts @@ -45,6 +45,7 @@ describe('Liquid', () => { it('loads a specific block page', () => { cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); }); @@ -71,20 +72,6 @@ describe('Liquid', () => { }); }); - it('renders unconfidential addresses correctly on mobile', () => { - cy.viewport('iphone-6'); - cy.visit(`${basePath}/address/ex1qqmmjdwrlg59c8q4l75sj6wedjx57tj5grt8pat`); - cy.waitForSkeletonGone(); - //TODO: Add proper IDs for these selectors - const firstRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(1) > .table > tbody'; - const thirdRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(3)'; - cy.get(firstRowSelector).invoke('css', 'width').then(firstRowWidth => { - cy.get(thirdRowSelector).invoke('css', 'width').then(thirdRowWidth => { - expect(parseInt(firstRowWidth)).to.be.lessThan(parseInt(thirdRowWidth)); - }); - }); - }); - describe('peg in/peg out', () => { it('loads peg in addresses', () => { cy.visit(`${basePath}/tx/fe764f7bedfc2a37b29d9c8aef67d64a57d253a6b11c5a55555cfd5826483a58`); diff --git a/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts b/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts index a96b0700c..54e355ce8 100644 --- a/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts +++ b/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts @@ -46,7 +46,8 @@ describe('Liquid Testnet', () => { }); it('loads a specific block page', () => { - cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`); + cy.visit(`${basePath}/block/fb4cbcbff3993ca4bf8caf657d55a23db5ed4ab1cfa33c489303c2e04e1c38e0`); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); }); diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index c0f5cbfda..a1082b769 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -103,6 +103,7 @@ describe('Mainnet', () => { it('check op_return tx tooltip', () => { cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); @@ -111,9 +112,10 @@ describe('Mainnet', () => { it('check op_return coinbase tooltip', () => { cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); - cy.get('div > a > .badge').first().trigger('onmouseover'); - cy.get('div > a > .badge').first().trigger('mouseenter'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); cy.get('.tooltip-inner').should('be.visible'); }); @@ -142,13 +144,13 @@ describe('Mainnet', () => { }); }); - ['BC1PQYQSZQ', 'bc1PqYqSzQ'].forEach((searchTerm) => { + ['BC1PQYQS', 'bc1PqYqS'].forEach((searchTerm) => { it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => { cy.visit('/'); cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { - cy.get('app-search-results button.dropdown-item').should('have.length', 1); + cy.get('app-search-results button.dropdown-item').should('have.length', 10); cy.get('app-search-results button.dropdown-item.active').click().then(() => { - cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e'); + cy.url().should('include', '/address/bc1pqyqs26fs4gnyw4aqttyjqa5ta7075zzfjftyz98qa8vdr49dh7fqm2zkv3'); cy.waitForSkeletonGone(); cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); }); @@ -156,13 +158,13 @@ describe('Mainnet', () => { }); }); - ['BC1Q000375VXCU', 'bC1q000375vXcU'].forEach((searchTerm) => { + ['BC1Q0003', 'bC1q0003'].forEach((searchTerm) => { it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => { cy.visit('/'); cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { - cy.get('app-search-results button.dropdown-item').should('have.length', 1); + cy.get('app-search-results button.dropdown-item').should('have.length', 10); cy.get('app-search-results button.dropdown-item.active').click().then(() => { - cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy'); + cy.url().should('include', '/address/bc1q000303cgr9zazthut63kdktwtatfe206um8nyh'); cy.waitForSkeletonGone(); cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); }); @@ -283,6 +285,7 @@ describe('Mainnet', () => { it('loads genesis block and keypress arrow right', () => { cy.viewport('macbook-16'); cy.visit('/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); @@ -295,6 +298,7 @@ describe('Mainnet', () => { it('loads genesis block and keypress arrow left', () => { cy.viewport('macbook-16'); cy.visit('/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); @@ -323,6 +327,7 @@ describe('Mainnet', () => { it('loads genesis block and click on the arrow left', () => { cy.viewport('macbook-16'); cy.visit('/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); @@ -339,7 +344,7 @@ describe('Mainnet', () => { cy.visit('/'); cy.waitForSkeletonGone(); - cy.changeNetwork('testnet'); + cy.changeNetwork('testnet4'); cy.changeNetwork('signet'); cy.changeNetwork('mainnet'); }); @@ -439,6 +444,7 @@ describe('Mainnet', () => { describe('blocks', () => { it('shows empty blocks properly', () => { cy.visit('/block/0000000000000000000bd14f744ef2e006e61c32214670de7eb891a5732ee775'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); cy.get('h2').invoke('text').should('equal', '1 transaction'); @@ -446,6 +452,7 @@ describe('Mainnet', () => { it('expands and collapses the block details', () => { cy.visit('/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); cy.get('.btn.btn-outline-info').click().then(() => { @@ -458,6 +465,7 @@ describe('Mainnet', () => { }); it('shows blocks with no pagination', () => { cy.visit('/block/00000000000000000001ba40caf1ad4cec0ceb77692662315c151953bfd7c4c4'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); cy.get('.block-tx-title h2').invoke('text').should('equal', '19 transactions'); @@ -467,6 +475,7 @@ describe('Mainnet', () => { it('supports pagination on the block screen', () => { // 41 txs cy.visit('/block/00000000000000000009f9b7b0f63ad50053ad12ec3b7f5ca951332f134f83d8'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('.pagination-container a').invoke('text').then((text1) => { cy.get('.active + li').first().click().then(() => { @@ -482,6 +491,7 @@ describe('Mainnet', () => { it('shows blocks pagination with 5 pages (desktop)', () => { cy.viewport(760, 800); cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => { + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); }); @@ -493,6 +503,7 @@ describe('Mainnet', () => { it('shows blocks pagination with 3 pages (mobile)', () => { cy.viewport(669, 800); cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => { + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.waitForPageIdle(); }); @@ -532,16 +543,7 @@ describe('Mainnet', () => { } }); - cy.get('.alert').should('be.visible'); - cy.get('.alert').invoke('css', 'width').then((alertWidth) => { - cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth); - }); - - cy.get('.btn-warning').then(getRectangle).then((rectA) => { - cy.get('.alert').then(getRectangle).then((rectB) => { - expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false; - }); - }); + cy.get('.alert-replaced').should('be.visible'); }); it('shows RBF transactions properly (desktop)', () => { diff --git a/frontend/cypress/e2e/signet/signet.spec.ts b/frontend/cypress/e2e/signet/signet.spec.ts index 03cfb3480..11c47d14d 100644 --- a/frontend/cypress/e2e/signet/signet.spec.ts +++ b/frontend/cypress/e2e/signet/signet.spec.ts @@ -95,12 +95,14 @@ describe('Signet', () => { describe('blocks', () => { it('shows empty blocks properly', () => { cy.visit('/signet/block/00000133d54e4589f6436703b067ec23209e0a21b8a9b12f57d0592fd85f7a42'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('h2').invoke('text').should('equal', '1 transaction'); }); it('expands and collapses the block details', () => { cy.visit('/signet/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('.btn.btn-outline-info').click().then(() => { cy.get('#details').should('be.visible'); @@ -113,6 +115,7 @@ describe('Signet', () => { it('shows blocks with no pagination', () => { cy.visit('/signet/block/00000078f920a96a69089877b934ce7fd009ab55e3170920a021262cb258e7cc'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('h2').invoke('text').should('equal', '13 transactions'); cy.get('ul.pagination').first().children().should('have.length', 5); @@ -121,6 +124,7 @@ describe('Signet', () => { it('supports pagination on the block screen', () => { // 43 txs cy.visit('/signet/block/00000094bd52f73bdbfc4bece3a94c21fec2dc968cd54210496e69e4059d66a6'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.active + li').first().click().then(() => { diff --git a/frontend/cypress/e2e/testnet/testnet.spec.ts b/frontend/cypress/e2e/testnet4/testnet4.spec.ts similarity index 80% rename from frontend/cypress/e2e/testnet/testnet.spec.ts rename to frontend/cypress/e2e/testnet4/testnet4.spec.ts index 4236ca207..c67d2414b 100644 --- a/frontend/cypress/e2e/testnet/testnet.spec.ts +++ b/frontend/cypress/e2e/testnet4/testnet4.spec.ts @@ -2,7 +2,7 @@ import { emitMempoolInfo } from '../../support/websocket'; const baseModule = Cypress.env('BASE_MODULE'); -describe('Testnet', () => { +describe('Testnet4', () => { beforeEach(() => { cy.intercept('/api/block-height/*').as('block-height'); cy.intercept('/api/block/*').as('block'); @@ -13,7 +13,7 @@ describe('Testnet', () => { if (baseModule === 'mempool') { it('loads the dashboard', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); }); @@ -25,7 +25,7 @@ describe('Testnet', () => { it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible'); @@ -45,7 +45,7 @@ describe('Testnet', () => { }); it('loads the pools screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-pools').click().then(() => { cy.wait(1000); @@ -53,7 +53,7 @@ describe('Testnet', () => { }); it('loads the graphs screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-graphs').click().then(() => { cy.wait(1000); @@ -63,7 +63,7 @@ describe('Testnet', () => { describe('tv mode', () => { it('loads the tv screen - desktop', () => { cy.viewport('macbook-16'); - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.wait(1000); @@ -73,7 +73,7 @@ describe('Testnet', () => { }); it('loads the tv screen - mobile', () => { - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.viewport('iphone-6'); @@ -85,7 +85,7 @@ describe('Testnet', () => { it('loads the api screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-docs').click().then(() => { cy.wait(1000); @@ -94,13 +94,15 @@ describe('Testnet', () => { describe('blocks', () => { it('shows empty blocks properly', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('h2').invoke('text').should('equal', '1 transaction'); }); it('expands and collapses the block details', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('.btn.btn-outline-info').click().then(() => { cy.get('#details').should('be.visible'); @@ -112,15 +114,17 @@ describe('Testnet', () => { }); it('shows blocks with no pagination', () => { - cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea'); + cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); - cy.get('h2').invoke('text').should('equal', '11 transactions'); + cy.get('h2').invoke('text').should('equal', '18 transactions'); cy.get('ul.pagination').first().children().should('have.length', 5); }); it('supports pagination on the block screen', () => { // 48 txs - cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9'); + cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3'); + cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } }); cy.waitForSkeletonGone(); cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.active + li').first().click().then(() => { diff --git a/frontend/cypress/fixtures/mainnet_mempoolInfo.json b/frontend/cypress/fixtures/mainnet_mempoolInfo.json index 5c41bd4dd..584364e9a 100644 --- a/frontend/cypress/fixtures/mainnet_mempoolInfo.json +++ b/frontend/cypress/fixtures/mainnet_mempoolInfo.json @@ -750,7 +750,7 @@ }, "backendInfo": { "hostname": "node205.tk7.mempool.space", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "gitCommit": "abbc8a134", "lightning": false }, diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 23376e5b2..018f63569 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -72,7 +72,7 @@ Cypress.Commands.add('mockMempoolSocket', () => { mockWebSocket(); }); -Cypress.Commands.add('changeNetwork', (network: "testnet" | "signet" | "liquid" | "mainnet") => { +Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => { cy.get('.dropdown-toggle').click().then(() => { cy.get(`a.${network}`).click().then(() => { cy.waitForPageIdle(); diff --git a/frontend/cypress/support/index.d.ts b/frontend/cypress/support/index.d.ts index 3cc151b21..2c5328301 100644 --- a/frontend/cypress/support/index.d.ts +++ b/frontend/cypress/support/index.d.ts @@ -5,6 +5,6 @@ declare namespace Cypress { waitForSkeletonGone(): Chainable waitForPageIdle(): Chainable mockMempoolSocket(): Chainable - changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable + changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable } } \ No newline at end of file diff --git a/frontend/generate-config.js b/frontend/generate-config.js index c7a81a482..89d7143fd 100644 --- a/frontend/generate-config.js +++ b/frontend/generate-config.js @@ -4,11 +4,14 @@ const { spawnSync } = require('child_process'); const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js'; const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js'; +const GENERATED_CUSTOMIZATION_FILE_NAME = 'src/resources/customize.js'; let settings = []; let configContent = {}; let gitCommitHash = ''; let packetJsonVersion = ''; +let customConfig; +let customConfigContent; try { const rawConfig = fs.readFileSync(CONFIG_FILE_NAME); @@ -22,7 +25,18 @@ try { } } -const indexFilePath = configContent.BASE_MODULE ? 'src/index.' + configContent.BASE_MODULE + '.html' : 'src/index.mempool.html'; +if (configContent && configContent.CUSTOMIZATION) { + try { + customConfig = readConfig(configContent.CUSTOMIZATION); + customConfigContent = JSON.parse(customConfig); + } catch (e) { + console.log(`failed to load customization config from ${configContent.CUSTOMIZATION}`); + } +} + +const baseModuleName = configContent.BASE_MODULE || 'mempool'; +const customBuildName = (customConfigContent && customConfigContent.enterprise) ? ('.' + customConfigContent.enterprise) : ''; +const indexFilePath = 'src/index.' + baseModuleName + customBuildName + '.html'; try { fs.copyFileSync(indexFilePath, 'src/index.html'); @@ -109,6 +123,17 @@ writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); +let customConfigJs = ''; +if (customConfig) { + console.log(`Customizing frontend using ${configContent.CUSTOMIZATION}`); + customConfigJs = `(function (window) { + window.__env = window.__env || {}; + window.__env.customize = ${customConfig}; + }((typeof global !== 'undefined') ? global : this)); + `; +} +writeConfig(GENERATED_CUSTOMIZATION_FILE_NAME, customConfigJs); + if (currentConfig && currentConfig === newConfig) { console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`); return; diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 7f06c8fbc..f9f2576d6 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -1,8 +1,10 @@ { "TESTNET_ENABLED": false, + "TESTNET4_ENABLED": false, "SIGNET_ENABLED": false, "LIQUID_ENABLED": false, "LIQUID_TESTNET_ENABLED": false, + "MAINNET_ENABLED": true, "ITEMS_PER_PAGE": 10, "KEEP_BLOCKS_AMOUNT": 8, "NGINX_PROTOCOL": "http", @@ -11,6 +13,7 @@ "BLOCK_WEIGHT_UNITS": 4000000, "MEMPOOL_BLOCKS_AMOUNT": 8, "BASE_MODULE": "mempool", + "ROOT_NETWORK": "", "MEMPOOL_WEBSITE_URL": "https://mempool.space", "LIQUID_WEBSITE_URL": "https://liquid.network", "MINING_DASHBOARD": true, @@ -22,5 +25,7 @@ "HISTORICAL_PRICE": true, "ADDITIONAL_CURRENCIES": false, "ACCELERATOR": false, - "PUBLIC_ACCELERATIONS": false + "ACCELERATOR_BUTTON": true, + "PUBLIC_ACCELERATIONS": false, + "SERVICES_API": "https://mempool.space/api/v1/services" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e2cbeada..c17e706af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-frontend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-frontend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "license": "GNU Affero General Public License v3.0", "dependencies": { "@angular-devkit/build-angular": "^17.3.1", @@ -23,26 +23,27 @@ "@angular/router": "^17.3.1", "@angular/ssr": "^17.3.1", "@fortawesome/angular-fontawesome": "~0.14.1", - "@fortawesome/fontawesome-common-types": "~6.5.1", - "@fortawesome/fontawesome-svg-core": "~6.5.1", - "@fortawesome/free-solid-svg-icons": "~6.5.1", + "@fortawesome/fontawesome-common-types": "~6.6.0", + "@fortawesome/fontawesome-svg-core": "~6.6.0", + "@fortawesome/free-solid-svg-icons": "~6.6.0", "@mempool/mempool.js": "2.3.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0", "@types/qrcode": "~1.5.0", "bootstrap": "~4.6.2", "browserify": "^17.0.0", "clipboard": "^2.0.11", + "cypress": "^13.14.0", "domino": "^2.1.6", "echarts": "~5.5.0", - "esbuild": "^0.20.2", + "esbuild": "^0.23.0", "lightweight-charts": "~3.8.0", - "ngx-echarts": "~17.1.0", + "ngx-echarts": "~17.2.0", "ngx-infinite-scroll": "^17.0.0", "qrcode": "1.5.1", "rxjs": "~7.8.1", "tinyify": "^4.0.0", "tlite": "^0.1.9", - "tslib": "~2.6.0", + "tslib": "~2.7.0", "zone.js": "~0.14.4" }, "devDependencies": { @@ -62,7 +63,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.7.0", + "cypress": "^13.14.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", @@ -699,6 +700,11 @@ "node": ">=10" } }, + "node_modules/@angular-devkit/build-angular/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1703.1", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.1.tgz", @@ -3196,9 +3202,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", + "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", "cpu": [ "ppc64" ], @@ -3207,13 +3213,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", + "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", "cpu": [ "arm" ], @@ -3222,13 +3228,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", + "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", "cpu": [ "arm64" ], @@ -3237,13 +3243,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", + "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", "cpu": [ "x64" ], @@ -3252,13 +3258,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", + "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", "cpu": [ "arm64" ], @@ -3267,13 +3273,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", + "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", "cpu": [ "x64" ], @@ -3282,13 +3288,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", + "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", "cpu": [ "arm64" ], @@ -3297,13 +3303,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", + "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", "cpu": [ "x64" ], @@ -3312,13 +3318,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", + "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", "cpu": [ "arm" ], @@ -3327,13 +3333,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", + "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", "cpu": [ "arm64" ], @@ -3342,13 +3348,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", + "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", "cpu": [ "ia32" ], @@ -3357,13 +3363,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", + "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", "cpu": [ "loong64" ], @@ -3372,13 +3378,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", + "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", "cpu": [ "mips64el" ], @@ -3387,13 +3393,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", + "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", "cpu": [ "ppc64" ], @@ -3402,13 +3408,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", + "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", "cpu": [ "riscv64" ], @@ -3417,13 +3423,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", + "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", "cpu": [ "s390x" ], @@ -3432,13 +3438,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", "cpu": [ "x64" ], @@ -3447,13 +3453,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", + "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", "cpu": [ "x64" ], @@ -3462,13 +3468,28 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", + "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", + "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", "cpu": [ "x64" ], @@ -3477,13 +3498,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", + "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", "cpu": [ "x64" ], @@ -3492,13 +3513,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", + "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", "cpu": [ "arm64" ], @@ -3507,13 +3528,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", + "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", "cpu": [ "ia32" ], @@ -3522,13 +3543,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", + "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", "cpu": [ "x64" ], @@ -3537,7 +3558,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -3654,33 +3675,30 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", - "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", - "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" @@ -6105,11 +6123,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -8028,13 +8046,13 @@ "peer": true }, "node_modules/cypress": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.0.tgz", - "integrity": "sha512-UimjRSJJYdTlvkChcdcfywKJ6tUYuwYuk/n1uMMglrvi+ZthNhoRYcxnWgTqUtkl17fXrPAsD5XT2rcQYN1xKA==", + "version": "13.14.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.14.0.tgz", + "integrity": "sha512-r0+nhd033x883YL6068futewUsl02Q7rWiinyAAIBDW/OOTn+UMILWgNuCiY3vtJjd53efOqq5R9dctQk/rKiw==", "hasInstallScript": true, "optional": true, "dependencies": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.1", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -8073,7 +8091,7 @@ "request-progress": "^3.0.0", "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.1", + "tmp": "~0.2.3", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -8250,15 +8268,12 @@ } }, "node_modules/cypress/node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "optional": true, - "dependencies": { - "rimraf": "^3.0.0" - }, "engines": { - "node": ">=8.17.0" + "node": ">=14.14" } }, "node_modules/d": { @@ -8796,9 +8811,9 @@ "integrity": "sha512-XzWNH4ZSa9BwVUQSDorPWAUQ5WGuYz7zJUNpNif40zFCiCl20t8zgylmreNmn26h5kiyw2lg7RfTmeMBsDklqg==" }, "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", @@ -9196,40 +9211,41 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", + "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.23.0", + "@esbuild/android-arm": "0.23.0", + "@esbuild/android-arm64": "0.23.0", + "@esbuild/android-x64": "0.23.0", + "@esbuild/darwin-arm64": "0.23.0", + "@esbuild/darwin-x64": "0.23.0", + "@esbuild/freebsd-arm64": "0.23.0", + "@esbuild/freebsd-x64": "0.23.0", + "@esbuild/linux-arm": "0.23.0", + "@esbuild/linux-arm64": "0.23.0", + "@esbuild/linux-ia32": "0.23.0", + "@esbuild/linux-loong64": "0.23.0", + "@esbuild/linux-mips64el": "0.23.0", + "@esbuild/linux-ppc64": "0.23.0", + "@esbuild/linux-riscv64": "0.23.0", + "@esbuild/linux-s390x": "0.23.0", + "@esbuild/linux-x64": "0.23.0", + "@esbuild/netbsd-x64": "0.23.0", + "@esbuild/openbsd-arm64": "0.23.0", + "@esbuild/openbsd-x64": "0.23.0", + "@esbuild/sunos-x64": "0.23.0", + "@esbuild/win32-arm64": "0.23.0", + "@esbuild/win32-ia32": "0.23.0", + "@esbuild/win32-x64": "0.23.0" } }, "node_modules/esbuild-wasm": { @@ -10151,9 +10167,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -13289,9 +13305,9 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/ngx-echarts": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz", - "integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz", + "integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==", "dependencies": { "tslib": "^2.3.0" }, @@ -16915,9 +16931,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/tuf-js": { "version": "2.2.0", @@ -18839,6 +18855,11 @@ "requires": { "lru-cache": "^6.0.0" } + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" } } }, @@ -20562,141 +20583,147 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==" }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", + "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", + "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", + "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", + "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", + "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", + "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", + "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", + "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", + "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", + "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", + "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", + "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", + "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", + "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", + "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", + "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", + "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", + "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", + "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", + "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", + "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", + "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", + "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", "optional": true }, "@eslint-community/eslint-utils": { @@ -20778,24 +20805,24 @@ } }, "@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==" + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==" }, "@fortawesome/fontawesome-svg-core": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", - "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.6.0" } }, "@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", - "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.6.0" } }, "@goto-bus-stop/common-shake": { @@ -22635,11 +22662,11 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "brorand": { @@ -24111,12 +24138,12 @@ "peer": true }, "cypress": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.0.tgz", - "integrity": "sha512-UimjRSJJYdTlvkChcdcfywKJ6tUYuwYuk/n1uMMglrvi+ZthNhoRYcxnWgTqUtkl17fXrPAsD5XT2rcQYN1xKA==", + "version": "13.14.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.14.0.tgz", + "integrity": "sha512-r0+nhd033x883YL6068futewUsl02Q7rWiinyAAIBDW/OOTn+UMILWgNuCiY3vtJjd53efOqq5R9dctQk/rKiw==", "optional": true, "requires": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.1", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -24155,7 +24182,7 @@ "request-progress": "^3.0.0", "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.1", + "tmp": "~0.2.3", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -24265,13 +24292,10 @@ } }, "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "optional": true, - "requires": { - "rimraf": "^3.0.0" - } + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "optional": true } } }, @@ -24710,9 +24734,9 @@ "integrity": "sha512-XzWNH4ZSa9BwVUQSDorPWAUQ5WGuYz7zJUNpNif40zFCiCl20t8zgylmreNmn26h5kiyw2lg7RfTmeMBsDklqg==" }, "elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "requires": { "bn.js": "^4.11.9", "brorand": "^1.1.0", @@ -25031,33 +25055,34 @@ } }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", + "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.23.0", + "@esbuild/android-arm": "0.23.0", + "@esbuild/android-arm64": "0.23.0", + "@esbuild/android-x64": "0.23.0", + "@esbuild/darwin-arm64": "0.23.0", + "@esbuild/darwin-x64": "0.23.0", + "@esbuild/freebsd-arm64": "0.23.0", + "@esbuild/freebsd-x64": "0.23.0", + "@esbuild/linux-arm": "0.23.0", + "@esbuild/linux-arm64": "0.23.0", + "@esbuild/linux-ia32": "0.23.0", + "@esbuild/linux-loong64": "0.23.0", + "@esbuild/linux-mips64el": "0.23.0", + "@esbuild/linux-ppc64": "0.23.0", + "@esbuild/linux-riscv64": "0.23.0", + "@esbuild/linux-s390x": "0.23.0", + "@esbuild/linux-x64": "0.23.0", + "@esbuild/netbsd-x64": "0.23.0", + "@esbuild/openbsd-arm64": "0.23.0", + "@esbuild/openbsd-x64": "0.23.0", + "@esbuild/sunos-x64": "0.23.0", + "@esbuild/win32-arm64": "0.23.0", + "@esbuild/win32-ia32": "0.23.0", + "@esbuild/win32-x64": "0.23.0" } }, "esbuild-wasm": { @@ -25756,9 +25781,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "requires": { "to-regex-range": "^5.0.1" } @@ -28068,9 +28093,9 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "ngx-echarts": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz", - "integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz", + "integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==", "requires": { "tslib": "^2.3.0" } @@ -30749,9 +30774,9 @@ } }, "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "tuf-js": { "version": "2.2.0", diff --git a/frontend/package.json b/frontend/package.json index 5864a2284..3b5d61be0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-frontend", - "version": "3.0.0-dev", + "version": "3.1.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", @@ -50,16 +50,16 @@ "dev:ssr": "npm run generate-config && ng run mempool:serve-ssr", "serve:ssr": "npm run generate-config && node server.run.js", "build:ssr": "npm run build && ng run mempool:server:production && ./node_modules/typescript/bin/tsc server.run.ts", - "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", - "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", + "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", + "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", "prerender": "npm run ng -- run mempool:prerender", "cypress:open": "cypress open", "cypress:run": "cypress run", "cypress:run:record": "cypress run --record", - "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", - "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", - "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", - "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" + "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", + "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", + "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", + "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" }, "dependencies": { "@angular-devkit/build-angular": "^17.3.1", @@ -76,9 +76,9 @@ "@angular/router": "^17.3.1", "@angular/ssr": "^17.3.1", "@fortawesome/angular-fontawesome": "~0.14.1", - "@fortawesome/fontawesome-common-types": "~6.5.1", - "@fortawesome/fontawesome-svg-core": "~6.5.1", - "@fortawesome/free-solid-svg-icons": "~6.5.1", + "@fortawesome/fontawesome-common-types": "~6.6.0", + "@fortawesome/fontawesome-svg-core": "~6.6.0", + "@fortawesome/free-solid-svg-icons": "~6.6.0", "@mempool/mempool.js": "2.3.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0", "@types/qrcode": "~1.5.0", @@ -88,14 +88,14 @@ "domino": "^2.1.6", "echarts": "~5.5.0", "lightweight-charts": "~3.8.0", - "ngx-echarts": "~17.1.0", + "ngx-echarts": "~17.2.0", "ngx-infinite-scroll": "^17.0.0", "qrcode": "1.5.1", "rxjs": "~7.8.1", - "esbuild": "^0.20.2", + "esbuild": "^0.23.0", "tinyify": "^4.0.0", "tlite": "^0.1.9", - "tslib": "~2.6.0", + "tslib": "~2.7.0", "zone.js": "~0.14.4" }, "devDependencies": { @@ -115,7 +115,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.7.0", + "cypress": "^13.14.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", diff --git a/frontend/proxy.conf.js b/frontend/proxy.conf.js index b63d343e2..05f7550e0 100644 --- a/frontend/proxy.conf.js +++ b/frontend/proxy.conf.js @@ -24,7 +24,7 @@ PROXY_CONFIG = [ '/api/**', '!/api/v1/ws', '!/liquid', '!/liquid/**', '!/liquid/', '!/liquidtestnet', '!/liquidtestnet/**', '!/liquidtestnet/', - '/testnet/api/**', '/signet/api/**' + '/testnet/api/**', '/signet/api/**', '/testnet4/api/**' ], target: "https://mempool.space", ws: true, diff --git a/frontend/proxy.conf.local-esplora.js b/frontend/proxy.conf.local-esplora.js index 0cdc9d459..905910294 100644 --- a/frontend/proxy.conf.local-esplora.js +++ b/frontend/proxy.conf.local-esplora.js @@ -78,6 +78,18 @@ PROXY_CONFIG.push(...[ "^/testnet": "" }, }, + /* Optional proxy to route dev to official acceleration services + { + context: ['/api/v1/services/accelerator/**'], + target: `https://mempool.space/api/v1/services/accelerator/`, + secure: false, + changeOrigin: true, + proxyTimeout: 30000, + pathRewrite: { + "^/api/v1/services/accelerator": "" + }, + }, + */ { context: ['/api/v1/services/**'], target: `http://localhost:9000`, diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7ec9a37d3..1f2e3f531 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -7,6 +7,9 @@ import { MempoolBlockViewComponent } from './components/mempool-block-view/mempo import { ClockComponent } from './components/clock/clock.component'; import { StatusViewComponent } from './components/status-view/status-view.component'; import { AddressGroupComponent } from './components/address-group/address-group.component'; +import { TrackerComponent } from './components/tracker/tracker.component'; +import { AccelerateCheckout } from './components/accelerate-checkout/accelerate-checkout.component'; +import { TrackerGuard } from './route-guards'; const browserWindow = window || {}; // @ts-ignore @@ -51,6 +54,44 @@ let routes: Routes = [ }, ] }, + { + path: 'testnet4', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), + data: { preload: true }, + }, + { + path: '', + loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), + data: { preload: true }, + }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, + { + path: 'status', + data: { networks: ['bitcoin', 'liquid'] }, + component: StatusViewComponent + }, + { + path: '', + loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), + data: { preload: true }, + }, + { + path: '**', + redirectTo: '/testnet4' + }, + ] + }, { path: 'signet', children: [ @@ -100,6 +141,12 @@ let routes: Routes = [ loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), data: { preload: true }, }, + { + path: 'tx', + canMatch: [TrackerGuard], + runGuardsAndResolvers: 'always', + loadChildren: () => import('./components/tracker/tracker.module').then(m => m.TrackerModule), + }, { path: '', loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), @@ -124,6 +171,10 @@ let routes: Routes = [ path: 'testnet', loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) }, + { + path: 'testnet4', + loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) + }, { path: 'signet', loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) @@ -164,10 +215,6 @@ let routes: Routes = [ loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), data: { preload: true }, }, - { - path: '**', - redirectTo: '' - }, ]; if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { @@ -252,13 +299,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule), data: { preload: true }, }, - { - path: '**', - redirectTo: '' - }, ]; } +if (!window['isMempoolSpaceBuild']) { + routes.push({ + path: '**', + redirectTo: '' + }); +} + @NgModule({ imports: [RouterModule.forRoot(routes, { initialNavigation: 'enabledBlocking', diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 17105d97e..cef630984 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -1,4 +1,4 @@ -export const mempoolFeeColors = [ +export const defaultMempoolFeeColors = [ '557d00', '5d7d01', '637d02', @@ -39,6 +39,47 @@ export const mempoolFeeColors = [ 'ae005b', ]; +export const contrastMempoolFeeColors = [ + '0082e6', + '0984df', + '1285d9', + '1a87d2', + '2388cb', + '2c8ac5', + '358bbe', + '3e8db7', + '468eb0', + '4f90aa', + '5892a3', + '61939c', + '6a9596', + '72968f', + '7b9888', + '849982', + '8d9b7b', + '959c74', + '9e9e6e', + 'a79f67', + 'b0a160', + 'b9a35a', + 'c1a453', + 'caa64c', + 'd3a745', + 'dca93f', + 'e5aa38', + 'edac31', + 'f6ad2b', + 'ffaf24', + 'ffb01e', + 'ffb118', + 'ffb212', + 'ffb30c', + 'ffb406', + 'ffb500', + 'ffb600', + 'ffb700', + ]; + export const chartColors = [ "#D81B60", "#8E24AA", @@ -110,7 +151,7 @@ export const languages: Language[] = [ { code: 'fr', name: 'Français' }, // French // { code: 'gl', name: 'Galego' }, // Galician { code: 'ko', name: '한국어' }, // Korean -// { code: 'hr', name: 'Hrvatski' }, // Croatian + { code: 'hr', name: 'Hrvatski' }, // Croatian // { code: 'id', name: 'Bahasa Indonesia' },// Indonesian { code: 'hi', name: 'हिन्दी' }, // Hindi { code: 'ne', name: 'नेपाली' }, // Nepalese @@ -148,22 +189,22 @@ export const specialBlocks = { '0': { labelEvent: 'Genesis', labelEventCompleted: 'The Genesis of Bitcoin', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '210000': { labelEvent: 'Bitcoin\'s 1st Halving', labelEventCompleted: 'Block Subsidy has halved to 25 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '420000': { labelEvent: 'Bitcoin\'s 2nd Halving', labelEventCompleted: 'Block Subsidy has halved to 12.5 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '630000': { labelEvent: 'Bitcoin\'s 3rd Halving', labelEventCompleted: 'Block Subsidy has halved to 6.25 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '709632': { labelEvent: 'Taproot 🌱 activation', @@ -173,62 +214,62 @@ export const specialBlocks = { '840000': { labelEvent: 'Bitcoin\'s 4th Halving', labelEventCompleted: 'Block Subsidy has halved to 3.125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1050000': { labelEvent: 'Bitcoin\'s 5th Halving', labelEventCompleted: 'Block Subsidy has halved to 1.5625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1260000': { labelEvent: 'Bitcoin\'s 6th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.78125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1470000': { labelEvent: 'Bitcoin\'s 7th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.390625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1680000': { labelEvent: 'Bitcoin\'s 8th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.1953125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1890000': { labelEvent: 'Bitcoin\'s 9th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.09765625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2100000': { labelEvent: 'Bitcoin\'s 10th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.04882812 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2310000': { labelEvent: 'Bitcoin\'s 11th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.02441406 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2520000': { labelEvent: 'Bitcoin\'s 12th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.01220703 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2730000': { labelEvent: 'Bitcoin\'s 13th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00610351 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2940000': { labelEvent: 'Bitcoin\'s 14th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00305175 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '3150000': { labelEvent: 'Bitcoin\'s 15th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00152587 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], } }; diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index e3f585a25..50bbd88b9 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -12,6 +12,7 @@ import { PriceService } from './services/price.service'; import { EnterpriseService } from './services/enterprise.service'; import { WebsocketService } from './services/websocket.service'; import { AudioService } from './services/audio.service'; +import { PreloadService } from './services/preload.service'; import { SeoService } from './services/seo.service'; import { OpenGraphService } from './services/opengraph.service'; import { ZoneService } from './services/zone-shim.service'; @@ -19,12 +20,14 @@ import { SharedModule } from './shared/shared.module'; import { StorageService } from './services/storage.service'; import { HttpCacheInterceptor } from './services/http-cache.interceptor'; import { LanguageService } from './services/language.service'; +import { ThemeService } from './services/theme.service'; import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe'; import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe'; import { AppPreloadingStrategy } from './app.preloading-strategy'; import { ServicesApiServices } from './services/services-api.service'; +import { DatePipe } from '@angular/common'; const providers = [ ElectrsApiService, @@ -38,12 +41,15 @@ const providers = [ StorageService, EnterpriseService, LanguageService, + ThemeService, ShortenStringPipe, FiatShortenerPipe, FiatCurrencyPipe, CapAddressPipe, + DatePipe, AppPreloadingStrategy, ServicesApiServices, + PreloadService, { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true }, { provide: ZONE_SERVICE, useClass: ZoneService }, ]; diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index d5b5122fa..ae522121c 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -1,4 +1,5 @@ import { Transaction, Vin } from './interfaces/electrs.interface'; +import { Hash } from './shared/sha256'; const P2SH_P2WPKH_COST = 21 * 4; // the WU cost for the non-witness part of P2SH-P2WPKH const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH-P2WSH @@ -70,19 +71,24 @@ export function calcSegwitFeeGains(tx: Transaction) { } if (isP2tr) { - if (vin.witness.length === 1) { - // key path spend - // we don't know if this was a multisig or single sig (the goal of taproot :)), - // so calculate fee savings by comparing to the cheapest single sig input type: P2WPKH and say "saved at least ...%" - // the witness size of P2WPKH is 1 (stack size) + 1 (size) + 72 (low s signature) + 1 (size) + 33 (pubkey) = 108 WU - // the witness size of key path P2TR is 1 (stack size) + 1 (size) + 64 (signature) = 66 WU - realizedTaprootGains += 42; - } else { - // script path spend - // complex scripts with multiple spending paths can often be made around 2x to 3x smaller with the Taproot script tree - // because only the hash of the alternative spending path has the be in the witness data, not the entire script, - // but only assumptions can be made because the scripts themselves are unknown (again, the goal of taproot :)) - // TODO maybe add some complex scripts that are specified somewhere, so that size is known, such as lightning scripts + // every valid taproot input has at least one witness item, however transactions + // created before taproot activation don't need to have any witness data + // (see https://mempool.space/tx/b10c007c60e14f9d087e0291d4d0c7869697c6681d979c6639dbd960792b4d41) + if (vin.witness?.length) { + if (vin.witness.length === 1) { + // key path spend + // we don't know if this was a multisig or single sig (the goal of taproot :)), + // so calculate fee savings by comparing to the cheapest single sig input type: P2WPKH and say "saved at least ...%" + // the witness size of P2WPKH is 1 (stack size) + 1 (size) + 72 (low s signature) + 1 (size) + 33 (pubkey) = 108 WU + // the witness size of key path P2TR is 1 (stack size) + 1 (size) + 64 (signature) = 66 WU + realizedTaprootGains += 42; + } else { + // script path spend + // complex scripts with multiple spending paths can often be made around 2x to 3x smaller with the Taproot script tree + // because only the hash of the alternative spending path has the be in the witness data, not the entire script, + // but only assumptions can be made because the scripts themselves are unknown (again, the goal of taproot :)) + // TODO maybe add some complex scripts that are specified somewhere, so that size is known, such as lightning scripts + } } } else { const script = isP2shP2Wsh || isP2wsh ? vin.inner_witnessscript_asm : vin.inner_redeemscript_asm; @@ -129,7 +135,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb return; } const opN = ops.pop(); - if (!opN.startsWith('OP_PUSHNUM_')) { + if (opN !== 'OP_0' && !opN.startsWith('OP_PUSHNUM_')) { return; } const n = parseInt(opN.match(/[0-9]+/)[0], 10); @@ -146,7 +152,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb } } const opM = ops.pop(); - if (!opM.startsWith('OP_PUSHNUM_')) { + if (opM !== 'OP_0' && !opM.startsWith('OP_PUSHNUM_')) { return; } const m = parseInt(opM.match(/[0-9]+/)[0], 10); @@ -266,6 +272,11 @@ const featureActivation = { segwit: 872730, taproot: 2032291, }, + testnet4: { + rbf: 0, + segwit: 0, + taproot: 0, + }, signet: { rbf: 0, segwit: 0, @@ -287,8 +298,8 @@ export async function calcScriptHash$(script: string): Promise { throw new Error('script is not a valid hex string'); } const buf = Uint8Array.from(script.match(/.{2}/g).map((byte) => parseInt(byte, 16))); - const hashBuffer = await crypto.subtle.digest('SHA-256', buf); - const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hash = new Hash().update(buf).digest(); + const hashArray = Array.from(new Uint8Array(hash)); return hashArray .map((bytes) => bytes.toString(16).padStart(2, '0')) .join(''); diff --git a/frontend/src/app/components/about/about-sponsors.component.scss b/frontend/src/app/components/about/about-sponsors.component.scss index 7c01bb9a3..0ee27d1f5 100644 --- a/frontend/src/app/components/about/about-sponsors.component.scss +++ b/frontend/src/app/components/about/about-sponsors.component.scss @@ -14,7 +14,7 @@ } .become-sponsor { - background-color: #1d1f31; + background-color: var(--bg); border-radius: 16px; padding: 12px 20px; width: 400px; diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 4d2be4744..406835572 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -53,13 +53,26 @@ Spiral - - - - - - - + + + + + + + + + + + + @@ -112,17 +125,14 @@ Blockstream - + + + Unchained - - - - - - - - Gemini + + + Bitkey Bull Bitcoin - + @@ -178,6 +188,19 @@ Exodus + + + + + + + + Gemini + + + + Leather + @@ -188,8 +211,8 @@ @@ -343,8 +366,8 @@ - - + + @@ -354,8 +377,8 @@

Project Translators

@@ -369,7 +392,7 @@
- + {{ contributor.name }} @@ -380,8 +403,8 @@

Project Members

- - + + {{ contributor.name }} @@ -392,11 +415,11 @@

Project Maintainers

- + softsimon - + wiz @@ -422,7 +445,7 @@ Trademark Notice

- The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®, Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full Bitcoin ecosystem®, Mempool Goggles™, the mempool logo, the mempool Square logo, the mempool Blocks logo, the mempool Blocks 3 | 2 logo, the mempool.space Vertical Logo, and the mempool.space Horizontal logo are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries. + The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®, Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full Bitcoin ecosystem®, Mempool Goggles™, the mempool Logo, the mempool Square Logo, the mempool block visualization Logo, the mempool Blocks Logo, the mempool transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo, the mempool.space Vertical Logo, and the mempool.space Horizontal Logo are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries.

While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our Trademark Policy and Guidelines for more details, published on <https://mempool.space/trademark-policy>. diff --git a/frontend/src/app/components/about/about.component.scss b/frontend/src/app/components/about/about.component.scss index f7aa0b965..6a20239cc 100644 --- a/frontend/src/app/components/about/about.component.scss +++ b/frontend/src/app/components/about/about.component.scss @@ -10,14 +10,9 @@ margin: 25px; line-height: 32px; } - .unknown { - border: 1px solid #b4b4b4; - } .image.not-rounded { border-radius: 0; - width: 60px; - height: 60px; } .intro { @@ -129,8 +124,9 @@ position: relative; width: 300px; } - .bisq { - top: 3px; + .sv { + height: 85px; + width: auto; position: relative; } } @@ -158,6 +154,11 @@ } img, svg { margin: 40px 29px 10px; + &.image.coldcard { + border-radius: 0; + height: auto; + margin: 20px 29px 20px; + } } } } @@ -177,6 +178,10 @@ } } + #project-members a.project-member-avatar img { + margin: 40px 20px 10px; + } + .copyright { text-align: left; max-width: 620px; @@ -246,3 +251,12 @@ width: 64px; height: 64px; } + +.enterprise-sponsor { + .wrapper { + display: flex; + flex-wrap: wrap; + justify-content: center; + max-width: 800px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html new file mode 100644 index 000000000..df67de65c --- /dev/null +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -0,0 +1,600 @@ +

+ @if (accelerateError) { +
+
+

Sorry, something went wrong!

+
+
+
+
+
+ We were not able to accelerate this transaction. Please try again later. +
+
+
+
+
+
+ +
+
+ } @else if (step === 'quote') { +
+ + + + + +
+ @if (showDetails) { +
Your transaction
+
+
+ + Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor(s) + + + + + + + + + + + + + + + + + + +
Virtual size
+ Size in vbytes of this transaction (including unconfirmed ancestors) +
In-band fees + {{ estimate.txSummary.effectiveFee | number : '1.0-0' }} sats +
+ Fees already paid by this transaction (including unconfirmed ancestors) +
+
+
+
+ } +
How much faster?
+
+
+ + + This will reduce your expected waiting time until the first confirmation to + +
+
+ +
+
+
+
+
+
+
+ + + +
+
+
+
+
+ +
Summary
+
+
+ + + + + @if (hasAccessToBalanceMode) { + + + + + + + + + + + } + @else { + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Next block market rate + {{ estimate.targetFeeRate | number : '1.0-0' }} + sat/vB
+ Estimated extra fee required + + {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} + + sats + +
Target rate + {{ maxRateOptions[selectFeeRateIndex].rate | number : '1.0-0' }} + sat/vB
+ Extra fee required + + {{ maxRateOptions[selectFeeRateIndex].fee | number }} + + sats + +
Mempool Accelerator™ fees
+ Accelerator Service Fee + + +{{ estimate.mempoolBaseFee | number }} + + sats + +
+ Transaction Size Surcharge + + +{{ estimate.vsizeFee | number }} + + sats + +
+ Estimated acceleration cost ~{{ estimate.targetFeeRate | number : '1.0-0' }} sat/vB + + + {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} + + + sats + +
+ @if (hasAccessToBalanceMode) { + Maximum acceleration cost + } @else { + Acceleration cost + } + + + {{ cost | number }} + + + sats + + + +
Available balance + {{ estimate.userBalance | number }} + + sats + + + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ + +
+
+
+ } + @else if (step === 'summary') { + + + @if (!noCTA) { +
+
+

Accelerate your Bitcoin transaction?

+
+
+ } + + @if (!advancedEnabled) { +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+ } @else { +
+
+
+
+
+ +
+
+
+
+ + +
+ +
+
+ } +
+ +
+
+
+
+
+
+
+
+ } @else if (step === 'checkout') { + +
+
+
+ + + @if (!calculating) { + For an additional ({{ cost | number }} sats) + } @else { + Calculating cost... + } + + + Reducing expected confirmation time to + +
+
+
+ + +
+
+
+
+

Payment to mempool.space for acceleration of txid {{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}

+
+ @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) { +
+
+

Your account will be debited no more than {{ cost | number }} sats

+
+ +
+
+
+ } @else { +
+ @if (canPayWithBitcoin) { +
+ @if (invoice) { +

Pay {{ ((invoice.btcDue * 100_000_000) || cost) | number }} sats

+ + } @else if (btcpayInvoiceFailed) { +

Failed to load invoice

+
+ +
+ } @else { +

Loading invoice...

+
+
+
+ } +
+ @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) { +
+

OR

+
+ } + } + @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) { +
+

Pay  with

+ @if (canPayWithCashapp) { + + } + @if (canPayWithApplePay) { + @if (canPayWithCashapp) { } +
+ +
+ } + @if (canPayWithGooglePay) { + @if (canPayWithCashapp || canPayWithApplePay) { } +
+ +
+ } +
+ } +
+ } +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+ } @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') { + +
+
+

Confirm your payment

+
+
+ +
+
+
+ Payment to mempool.space for acceleration of txid {{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }} +
+
+
+ + @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) { +
+
+
+ Total additional cost
+ + Pay + + with + +
+
+
+
+ } + +
+
+
+ @if (step === 'applepay') { +
+ } @else if (step === 'cashapp') { +
+ } @else if (step === 'googlepay') { +
+ } + @if (loadingCashapp || loadingApplePay || loadingGooglePay) { +
+ Loading payment method... +
+
+ } +
+
+
+ +
+
+
+ +
+
+ } + @else if (step === 'processing') { +
+
+

Confirming your payment

+
+
+ +
+
+
+ +
+
+ We are processing your payment... +
+
+
+
+
+ } + @else if (step === 'paid') { +
+
+

Accelerating your transaction

+
+
+ +
+
+
+ Confirming your acceleration with our mining pool partners... + @if (timeSincePaid > 30000) { + ...sorry, this is taking longer than expected... + } +
+
+
+
+ } @else if (step === 'success') { +
+
+

Your transaction is being accelerated!

+
+
+
+
+
+ Your transaction has been accepted for acceleration by our mining pool partners. +
+
+
+
+
+
+ +
+
+ } +
+ + + + Confirmation expected 
+ @if (!calculating) { + ({{ cost | number }} sats) + } @else { + Calculating cost... + } +
+
+ + + + + +Accelerate to ~{{ x | number : '1.0-0' }} sat/vB + + +
+ + @if (quoteError || cantPayReason) { +
+ } +
+
+ + + @if (hasAccessToBalanceMode) { + + } @else { + + } + + +Your transaction will be prioritized by up to {{ i | number : '1.1-1' }}% of miners. diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss new file mode 100644 index 000000000..b35308384 --- /dev/null +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -0,0 +1,219 @@ +.close-button { + position: absolute; + top: 0.5em; + right: 0.5em; +} + +.estimating { + color: var(--green) +} + +.paymentMethod { + padding: 10px; + background-color: var(--secondary); + border-radius: 10px; + cursor: pointer; +} + +.default-slot:not(:only-child) { + display: none; +} + +.pie { + display: flex; + align-items: center; + max-width: 330px; +} + +.fee-card { + padding: 15px; + background-color: var(--bg); + + .feerate { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .rate { + font-size: 0.9em; + .symbol { + color: white; + } + } + } +} + +.btn-border { + border: solid 1px black; + background-color: #0c4a87; +} + +.feerate.active { + background-color: var(--primary) !important; + opacity: 1; + border: 1px solid #007fff !important; +} +.feerate:focus { + box-shadow: none !important; +} + +.grayOut { + opacity: 0.5; +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} + +.table-toggle { + width: 100%; + margin-top: 0.5em; +} + +.tab { + &:first-child { + margin-right: 1px; + } + border: solid 1px black; + border-bottom: none; + background-color: #323655; + border-top-left-radius: 10px !important; + border-top-right-radius: 10px !important; +} +.tab.active { + background-color: #5d659d !important; + opacity: 1; +} +.tab:focus { + box-shadow: none !important; +} + +.table-accelerator { + tr { + td { + padding-top: 0; + padding-bottom: 0; + vertical-align: baseline; + } + + &.group-first { + td { + padding-top: 0.75rem; + } + } + &.group-last, &:last-child { + td { + padding-bottom: 0.75rem; + } + } + &.dashed-top { + border-top: 1px dashed grey; + } + &.dashed-bottom { + border-bottom: 1px dashed grey + } + } + td { + &:first-child { + width: 100vw; + } + &.info { + color: #6c757d; + white-space: initial; + } + &.amt { + text-align: right; + padding-right: 0.2em; + } + &.units { + padding-left: 0.2em; + white-space: nowrap; + display: flex; + justify-content: space-between; + align-items: center; + } + } +} + +.accelerate-cols { + display: flex; + flex-direction: row; + align-items: stretch; + margin-top: 1em; +} + +.payment-area { + background: var(--bg); +} + +.col.pie { + flex-grow: 0; + padding: 0 1em; + position: relative; + top: -15px; +} + +.item { + white-space: initial; +} + +.table-background { + background-color: var(--bg); +} + +.checkout-text { + color: rgb(186, 186, 186); + font-size: 14px; +} + +.btn-accelerate { + background-color: var(--tertiary); +} + +.btn-small-height { + line-height: 1; +} + +.summary-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0 2em; + flex-wrap: wrap; + + @media (max-width: 640px) { + flex-direction: column; + } +} + +.btn-error { + position: absolute; + right: 0; + font-size: 12px; + color: var(--red); + text-align: center; + width: 200px; + white-space: normal; +} + +.btn-error-wrapper { + height: 26px; +} + +.apple-pay-button { + display: inline-block; + -webkit-appearance: -apple-pay-button; + -apple-pay-button-type: plain; /* Use any supported button type. */ +} +.apple-pay-button-black { + -apple-pay-button-style: black; +} +.apple-pay-button-white { + -apple-pay-button-style: white; +} +.apple-pay-button-white-with-line { + -apple-pay-button-style: white-outline; +} \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts new file mode 100644 index 000000000..6b1eadf7d --- /dev/null +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -0,0 +1,879 @@ +/* eslint-disable no-console */ +import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core'; +import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs'; +import { ServicesApiServices } from '../../services/services-api.service'; +import { md5, insecureRandomUUID } from '../../shared/common.utils'; +import { StateService } from '../../services/state.service'; +import { AudioService } from '../../services/audio.service'; +import { ETA, EtaService } from '../../services/eta.service'; +import { Transaction } from '../../interfaces/electrs.interface'; +import { MiningStats } from '../../services/mining.service'; +import { IAuth, AuthServiceMempool } from '../../services/auth.service'; +import { EnterpriseService } from '../../services/enterprise.service'; +import { ApiService } from '../../services/api.service'; +import { isDevMode } from '@angular/core'; + +export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay'; + +export type AccelerationEstimate = { + hasAccess: boolean; + txSummary: TxSummary; + nextBlockFee: number; + targetFeeRate: number; + userBalance: number; + enoughBalance: boolean; + cost: number; + mempoolBaseFee: number; + vsizeFee: number; + pools: number[]; + availablePaymentMethods: Record; + unavailable?: boolean; + options: { // recommended bid options + fee: number; // recommended userBid in sats + }[]; +} +export type TxSummary = { + txid: string; // txid of the current transaction + effectiveVsize: number; // Total vsize of the dependency tree + effectiveFee: number; // Total fee of the dependency tree in sats + ancestorCount: number; // Number of ancestors +} + +export interface RateOption { + fee: number; + rate: number; + index: number; +} + +export const MIN_BID_RATIO = 1; +export const DEFAULT_BID_RATIO = 2; +export const MAX_BID_RATIO = 4; + +type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'processing' | 'paid' | 'success'; + +@Component({ + selector: 'app-accelerate-checkout', + templateUrl: './accelerate-checkout.component.html', + styleUrls: ['./accelerate-checkout.component.scss'] +}) +export class AccelerateCheckout implements OnInit, OnDestroy { + @Input() tx: Transaction; + @Input() accelerating: boolean = false; + @Input() miningStats: MiningStats; + @Input() eta: ETA; + @Input() scrollEvent: boolean; + @Input() cashappEnabled: boolean = true; + @Input() applePayEnabled: boolean = false; + @Input() googlePayEnabled: boolean = true; + @Input() advancedEnabled: boolean = false; + @Input() forceMobile: boolean = false; + @Input() showDetails: boolean = false; + @Input() noCTA: boolean = false; + @Output() unavailable = new EventEmitter(); + @Output() completed = new EventEmitter(); + @Output() hasDetails = new EventEmitter(); + @Output() changeMode = new EventEmitter(); + + calculating = true; + selectedOption: 'wait' | 'accel'; + cantPayReason = ''; + quoteError = ''; // error fetching estimate or initial data + accelerateError = ''; // error executing acceleration + btcpayInvoiceFailed = false; + timePaid: number = 0; // time acceleration requested + math = Math; + isMobile: boolean = window.innerWidth <= 767.98; + isProdDomain = ['mempool.space', + 'mempool-staging.va1.mempool.space', + 'mempool-staging.fmt.mempool.space', + 'mempool-staging.fra.mempool.space', + 'mempool-staging.tk7.mempool.space', + 'mempool-staging.sg1.mempool.space' + ].indexOf(document.location.hostname) > -1; + + private _step: CheckoutStep = 'summary'; + simpleMode: boolean = true; + timeoutTimer: any; + + authSubscription$: Subscription; + auth: IAuth | null = null; + + // accelerator stuff + accelerationUUID: string; + accelerationSubscription: Subscription; + difficultySubscription: Subscription; + estimateSubscription: Subscription; + estimate: AccelerationEstimate; + maxBidBoost: number; // sats + cost: number; // sats + etaInfo$: Observable<{ hashratePercentage: number, ETA: number, acceleratedETA: number }>; + showSuccess = false; + hasAncestors: boolean = false; + minExtraCost = 0; + minBidAllowed = 0; + maxBidAllowed = 0; + defaultBid = 0; + userBid = 0; + selectFeeRateIndex = 1; + maxRateOptions: RateOption[] = []; + + // square + loadingCashapp = false; + loadingApplePay = false; + loadingGooglePay = false; + payments: any; + cashAppPay: any; + applePay: any; + googlePay: any; + conversionsSubscription: Subscription; + conversions: Record; + + // btcpay + loadingBtcpayInvoice = false; + invoice = undefined; + + constructor( + public stateService: StateService, + private apiService: ApiService, + private servicesApiService: ServicesApiServices, + private etaService: EtaService, + private audioService: AudioService, + private cd: ChangeDetectorRef, + private authService: AuthServiceMempool, + private enterpriseService: EnterpriseService, + ) { + this.accelerationUUID = insecureRandomUUID(); + + // Check if Apple Pay available + // https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview + if (window['ApplePaySession']) { + this.applePayEnabled = true; + } + } + + ngOnInit(): void { + this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => { + if (this.auth?.user?.userId !== auth?.user?.userId) { + this.auth = auth; + this.estimate = null; + this.quoteError = null; + this.accelerateError = null; + this.timePaid = 0; + this.btcpayInvoiceFailed = false; + this.moveToStep('summary'); + } else { + this.auth = auth; + } + }); + this.authService.refreshAuth$().subscribe(); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('cash_request_id')) { // Redirected from cashapp + this.moveToStep('processing'); + this.insertSquare(); + this.setupSquare(); + } else { + this.moveToStep('summary'); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + } + ); + } + + ngOnDestroy(): void { + if (this.estimateSubscription) { + this.estimateSubscription.unsubscribe(); + } + if (this.authSubscription$) { + this.authSubscription$.unsubscribe(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.scrollEvent && this.scrollEvent) { + this.scrollToElement('acceleratePreviewAnchor', 'start'); + } + if (changes.accelerating) { + if ((this.step === 'processing' || this.step === 'paid') && this.accelerating) { + this.moveToStep('success'); + } + } + } + + moveToStep(step: CheckoutStep): void { + this._step = step; + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + } + if (!this.estimate && ['quote', 'summary', 'checkout'].includes(this.step)) { + this.fetchEstimate(); + } + if (this._step === 'checkout') { + this.insertSquare(); + this.enterpriseService.goal(8); + } + if (this._step === 'checkout' && this.canPayWithBitcoin) { + this.btcpayInvoiceFailed = false; + this.loadingBtcpayInvoice = true; + this.invoice = null; + this.requestBTCPayInvoice(); + } else if (this._step === 'cashapp' && this.cashappEnabled) { + this.loadingCashapp = true; + this.setupSquare(); + this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'applepay' && this.applePayEnabled) { + this.loadingApplePay = true; + this.setupSquare(); + this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'googlepay' && this.googlePayEnabled) { + this.loadingGooglePay = true; + this.setupSquare(); + this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'paid') { + this.timePaid = Date.now(); + this.timeoutTimer = setTimeout(() => { + if (this.step === 'paid') { + this.accelerateError = 'internal_server_error'; + } + }, 120000); + } + this.hasDetails.emit(this._step === 'quote'); + } + + closeModal(): void { + this.completed.emit(true); + this.moveToStep('summary'); + } + + /** + * Scroll to element id with or without setTimeout + */ + scrollToElementWithTimeout(id: string, position: ScrollLogicalPosition, timeout: number = 1000): void { + setTimeout(() => { + this.scrollToElement(id, position); + }, timeout); + } + scrollToElement(id: string, position: ScrollLogicalPosition): void { + const acceleratePreviewAnchor = document.getElementById(id); + if (acceleratePreviewAnchor) { + this.cd.markForCheck(); + acceleratePreviewAnchor.scrollIntoView({ + behavior: 'smooth', + inline: position, + block: position, + }); + } + } + + /** + * Accelerator + */ + fetchEstimate(): void { + if (this.estimateSubscription) { + this.estimateSubscription.unsubscribe(); + } + this.calculating = true; + this.quoteError = null; + this.accelerateError = null; + this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe( + tap((response) => { + if (response.status === 204) { + this.quoteError = `cannot_accelerate_tx`; + if (this.step === 'summary') { + this.unavailable.emit(true); + } + } else { + this.estimate = response.body; + if (!this.estimate) { + this.quoteError = `cannot_accelerate_tx`; + if (this.step === 'summary') { + this.unavailable.emit(true); + } + return; + } + if (this.estimate.hasAccess === true && this.estimate.userBalance <= 0) { + if (this.isLoggedIn()) { + this.quoteError = `not_enough_balance`; + } + } + if (this.estimate.unavailable) { + this.quoteError = `temporarily_unavailable`; + } + this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; + this.etaInfo$ = this.etaService.getProjectedEtaObservable(this.estimate, this.miningStats); + + this.maxRateOptions = this.estimate.options.map((option, index) => ({ + fee: option.fee, + rate: (this.estimate.txSummary.effectiveFee + option.fee) / this.estimate.txSummary.effectiveVsize, + index + })); + + this.defaultBid = this.maxRateOptions[1].fee; + this.userBid = this.defaultBid; + this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; + + this.validateChoice(); + + if (!this.couldPay) { + this.quoteError = `cannot_accelerate_tx`; + if (this.step === 'summary') { + this.unavailable.emit(true); + } + return; + } + + if (this.step === 'checkout' && this.canPayWithBitcoin && !this.loadingBtcpayInvoice) { + this.loadingBtcpayInvoice = true; + this.requestBTCPayInvoice(); + } + + this.calculating = false; + this.cd.markForCheck(); + } + }), + + catchError(() => { + this.estimate = undefined; + this.quoteError = `cannot_accelerate_tx`; + this.estimateSubscription.unsubscribe(); + if (this.step === 'summary') { + this.unavailable.emit(true); + } else { + this.accelerateError = 'cannot_accelerate_tx'; + } + return of(null); + }) + ).subscribe(); + } + + validateChoice(): void { + if (!this.canPay) { + if (this.estimate?.availablePaymentMethods?.balance) { + if (this.cost >= this.estimate?.userBalance) { + this.cantPayReason = 'not_enough_balance'; + } + } else { + this.cantPayReason = 'cannot_accelerate_tx'; + } + } else { + this.cantPayReason = ''; + } + } + + /** + * User changed his bid + */ + setUserBid({ fee, index }: { fee: number, index: number}): void { + if (this.estimate) { + this.selectFeeRateIndex = index; + this.userBid = Math.max(0, fee); + this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; + } + } + + /** + * Account-based acceleration request + */ + accelerateWithMempoolAccount(): void { + if (!this.canPay || this.calculating) { + return; + } + if (this.accelerationSubscription) { + this.accelerationSubscription.unsubscribe(); + } + this.accelerationSubscription = this.servicesApiService.accelerate$( + this.tx.txid, + this.userBid, + this.accelerationUUID + ).subscribe({ + next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + this.showSuccess = true; + this.estimateSubscription.unsubscribe(); + this.moveToStep('paid'); + }, + error: (response) => { + this.accelerateError = response.error; + } + }); + } + + /** + * Square + */ + insertSquare(): void { + if (!this.isProdDomain && !isDevMode()) { + return; + } + if (window['Square']) { + return; + } + let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; + if (this.isProdDomain) { + statsUrl = '/square/v1/square.js'; + } + + (function(): void { + const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s); + })(); + } + setupSquare(): void { + if (!this.isProdDomain && !isDevMode()) { + return; + } + const init = (): void => { + this.initSquare(); + }; + + if (!window['Square']) { + console.debug('Square.js failed to load properly. Retrying.'); + setTimeout(this.setupSquare.bind(this), 100); + } else { + init(); + } + } + async initSquare(): Promise { + try { + this.servicesApiService.setupSquare$().subscribe({ + next: async (ids) => { + this.payments = window['Square'].payments(ids.squareAppId, ids.squareLocationId); + const urlParams = new URLSearchParams(window.location.search); + if (this._step === 'cashapp' || urlParams.get('cash_request_id')) { + await this.requestCashAppPayment(); + } else if (this._step === 'applepay') { + await this.requestApplePayPayment(); + } else if (this._step === 'googlepay') { + await this.requestGooglePayPayment(); + } + }, + error: () => { + console.debug('Error loading Square Payments'); + this.accelerateError = 'cannot_setup_square'; + } + }); + } catch (e) { + console.debug('Error loading Square Payments', e); + this.accelerateError = 'cannot_setup_square'; + } + } + + /** + * APPLE PAY + */ + async requestApplePayPayment(): Promise { + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + if (this.applePay) { + this.applePay.destroy(); + } + + const costUSD = this.cost / 100_000_000 * conversions.USD; + const paymentRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: costUSD.toFixed(2), + label: 'Total', + }, + }); + + try { + this.applePay = await this.payments.applePay(paymentRequest); + const applePayButton = document.getElementById('apple-pay-button'); + if (!applePayButton) { + console.error(`Unable to find apple pay button id='apple-pay-button'`); + // Try again + setTimeout(this.requestApplePayPayment.bind(this), 500); + return; + } + this.loadingApplePay = false; + applePayButton.addEventListener('click', async event => { + event.preventDefault(); + const tokenResult = await this.applePay.tokenize(); + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retreive payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + this.servicesApiService.accelerateWithApplePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + this.accelerationUUID + ).subscribe({ + next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.applePay) { + this.applePay.destroy(); + } + setTimeout(() => { + this.moveToStep('paid'); + }, 1000); + }, + error: (response) => { + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + // Reset everything by reloading the page :D, can be improved + const urlParams = new URLSearchParams(window.location.search); + window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); + }, 3000); + } + } + }); + } else { + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); + } + }); + } catch (e) { + console.error(e); + } + } + ); + } + + /** + * GOOGLE PAY + */ + async requestGooglePayPayment(): Promise { + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + if (this.googlePay) { + this.googlePay.destroy(); + } + + const costUSD = this.cost / 100_000_000 * conversions.USD; + const paymentRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: costUSD.toFixed(2), + label: 'Total' + } + }); + this.googlePay = await this.payments.googlePay(paymentRequest , { + referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + }); + + await this.googlePay.attach(`#google-pay-button`, { + buttonType: 'pay', + buttonSizeMode: 'fill', + }); + this.loadingGooglePay = false; + + document.getElementById('google-pay-button').addEventListener('click', async event => { + event.preventDefault(); + const tokenResult = await this.googlePay.tokenize(); + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retreive payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + this.accelerationUUID + ).subscribe({ + next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.googlePay) { + this.googlePay.destroy(); + } + setTimeout(() => { + this.moveToStep('paid'); + }, 1000); + }, + error: (response) => { + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + // Reset everything by reloading the page :D, can be improved + const urlParams = new URLSearchParams(window.location.search); + window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); + }, 3000); + } + } + }); + } else { + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); + } + }); + } + ); + } + + /** + * CASHAPP + */ + async requestCashAppPayment(): Promise { + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + if (this.cashAppPay) { + this.cashAppPay.destroy(); + } + + const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`; + const costUSD = this.step === 'processing' ? 69.69 : (this.cost / 100_000_000 * conversions.USD); // When we're redirected to this component, the payment data is already linked to the payment token, so does not matter what amonut we put in there, therefore it's 69.69 + const paymentRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: costUSD.toFixed(2), + label: 'Total', + pending: true, + productUrl: `${redirectHostname}/tx/${this.tx.txid}`, + } + }); + this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { + redirectURL: `${redirectHostname}/tx/${this.tx.txid}`, + referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}` + }); + + await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' }); + this.loadingCashapp = false; + + this.cashAppPay.addEventListener('ontokenization', event => { + const { tokenResult, error } = event.detail; + if (error) { + this.accelerateError = error; + } else if (tokenResult.status === 'OK') { + this.servicesApiService.accelerateWithCashApp$( + this.tx.txid, + tokenResult.token, + tokenResult.details.cashAppPay.cashtag, + tokenResult.details.cashAppPay.referenceId, + this.accelerationUUID + ).subscribe({ + next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.cashAppPay) { + this.cashAppPay.destroy(); + } + setTimeout(() => { + this.moveToStep('paid'); + if (window.history.replaceState) { + const urlParams = new URLSearchParams(window.location.search); + window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); + } + }, 1000); + }, + error: (response) => { + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + // Reset everything by reloading the page :D, can be improved + const urlParams = new URLSearchParams(window.location.search); + window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); + }, 3000); + } + } + }); + } + }); + } + ); + } + + /** + * BTCPay + */ + async requestBTCPayInvoice(): Promise { + this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe( + switchMap(response => { + return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId); + }), + catchError(error => { + console.log(error); + this.btcpayInvoiceFailed = true; + return of(null); + }) + ).subscribe((invoice) => { + this.invoice = invoice; + this.cd.markForCheck(); + }); + } + + bitcoinPaymentCompleted(): void { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + this.estimateSubscription.unsubscribe(); + this.moveToStep('paid'); + } + + isLoggedIn(): boolean { + return this.auth !== null; + } + + /** + * UI events + */ + selectedOptionChanged(event): void { + this.selectedOption = event.target.id; + } + + get step(): CheckoutStep { + return this._step; + } + + get paymentMethods(): PaymentMethod[] { + return Object.keys(this.estimate?.availablePaymentMethods || {}) as PaymentMethod[]; + } + + get couldPayWithBitcoin(): boolean { + return !!this.estimate?.availablePaymentMethods?.bitcoin; + } + + get couldPayWithCashapp(): boolean { + if (!this.cashappEnabled) { + return false; + } + return !!this.estimate?.availablePaymentMethods?.cashapp; + } + + get couldPayWithApplePay(): boolean { + if (!this.applePayEnabled) { + return false; + } + return !!this.estimate?.availablePaymentMethods?.applePay; + } + + get couldPayWithGooglePay(): boolean { + if (!this.googlePayEnabled) { + return false; + } + return !!this.estimate?.availablePaymentMethods?.googlePay; + } + + get couldPayWithBalance(): boolean { + if (!this.hasAccessToBalanceMode) { + return false; + } + return !!this.estimate?.availablePaymentMethods?.balance; + } + + get couldPay(): boolean { + return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay || this.couldPayWithGooglePay; + } + + get canPayWithBitcoin(): boolean { + const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin; + return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max; + } + + get canPayWithCashapp(): boolean { + if (!this.cashappEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) { + return false; + } + + const paymentMethod = this.estimate?.availablePaymentMethods?.cashapp; + if (paymentMethod) { + const costUSD = (this.cost / 100_000_000 * this.conversions.USD); + if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) { + return true; + } + } + + return false; + } + + get canPayWithApplePay(): boolean { + if (!this.applePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) { + return false; + } + + const paymentMethod = this.estimate?.availablePaymentMethods?.applePay; + if (paymentMethod) { + const costUSD = (this.cost / 100_000_000 * this.conversions.USD); + if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) { + return true; + } + } + + return false; + } + + get canPayWithGooglePay(): boolean { + if (!this.googlePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) { + return false; + } + + const paymentMethod = this.estimate?.availablePaymentMethods?.googlePay; + if (paymentMethod) { + const costUSD = (this.cost / 100_000_000 * this.conversions.USD); + if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) { + return true; + } + } + + return false; + } + + get canPayWithBalance(): boolean { + if (!this.hasAccessToBalanceMode) { + return false; + } + const paymentMethod = this.estimate?.availablePaymentMethods?.balance; + return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance; + } + + get canPay(): boolean { + return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay || this.canPayWithGooglePay; + } + + get hasAccessToBalanceMode(): boolean { + return this.isLoggedIn() && this.estimate?.hasAccess; + } + + get timeSincePaid(): number { + return Date.now() - this.timePaid; + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth <= 767.98; + } +} diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.html similarity index 75% rename from frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html rename to frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.html index fe0718ecc..a5e258210 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.html @@ -1,4 +1,4 @@ -
+
@@ -12,7 +12,7 @@

- {{ bar.class === 'tx' ? '' : '+' }} {{ bar.fee | number }} sat + {{ bar.class === 'tx' ? '' : '+' }}{{ bar.fee | number }} sat
diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.scss similarity index 94% rename from frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss rename to frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.scss index 8d36387f3..919fdec4a 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.scss @@ -4,13 +4,12 @@ width: 120px; margin-left: 4em; margin-right: 1.5em; - padding-bottom: 63px; .column { width: 100%; height: 100%; position: relative; - background: #181b2d; + background: var(--stat-box-bg); .bar { position: absolute; @@ -76,7 +75,7 @@ &.tx { .fill { - background: #3bcc49; + background: var(--green); } .line { .fee-rate { @@ -92,7 +91,7 @@ &.target { .fill { - background: #653b9c; + background: var(--tertiary); } .fee { position: absolute; @@ -114,7 +113,7 @@ } &.active, &:hover { .fill { - background: #105fb0; + background: var(--primary); } .line { .fee-rate .label { diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.ts new file mode 100644 index 000000000..393add6ca --- /dev/null +++ b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.ts @@ -0,0 +1,152 @@ +import { Component, Input, Output, OnChanges, EventEmitter, HostListener, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Transaction } from '../../interfaces/electrs.interface'; +import { AccelerationEstimate, RateOption } from './accelerate-checkout.component'; + +interface GraphBar { + rate: number; + style?: Record; + class: 'tx' | 'target' | 'max'; + label: string; + active?: boolean; + rateIndex?: number; + fee?: number; + height?: number; +} + +@Component({ + selector: 'app-accelerate-fee-graph', + templateUrl: './accelerate-fee-graph.component.html', + styleUrls: ['./accelerate-fee-graph.component.scss'], +}) +export class AccelerateFeeGraphComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { + @Input() tx: Transaction; + @Input() estimate: AccelerationEstimate; + @Input() showEstimate = false; + @Input() maxRateOptions: RateOption[] = []; + @Input() maxRateIndex: number = 0; + @Output() setUserBid = new EventEmitter<{ fee: number, index: number }>(); + + @ViewChild('feeGraph') + container: ElementRef; + height: number; + observer: ResizeObserver; + stopResizeLoop = false; + + bars: GraphBar[] = []; + tooltipPosition = { x: 0, y: 0 }; + + constructor( + private cd: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.initGraph(); + } + + ngAfterViewInit(): void { + if (ResizeObserver) { + this.observer = new ResizeObserver(entries => { + for (const entry of entries) { + this.height = entry.contentRect.height; + this.initGraph(); + } + }); + this.observer.observe(this.container.nativeElement); + } else { + this.startResizeFallbackLoop(); + } + } + + ngOnChanges(): void { + this.initGraph(); + } + + initGraph(): void { + if (!this.tx || !this.estimate) { + return; + } + const hasNextBlockRate = (this.estimate.nextBlockFee > this.estimate.txSummary.effectiveFee); + const numBars = hasNextBlockRate ? 4 : 3; + const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate)); + const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize; + let baseHeight = Math.max(this.height - (numBars * 30), this.height * (baseRate / maxRate)); + const bars: GraphBar[] = []; + let lastHeight = 0; + if (hasNextBlockRate) { + lastHeight = Math.max(lastHeight + 30, (this.height * ((this.estimate.targetFeeRate - baseRate) / maxRate))); + bars.push({ + rate: this.estimate.targetFeeRate, + height: lastHeight, + class: 'target', + label: $localize`:@@bdf0e930eb22431140a2eaeacd809cc5f8ebd38c:Next Block`.toLowerCase(), + fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee + }); + } + this.maxRateOptions.forEach((option, index) => { + lastHeight = Math.max(lastHeight + 30, (this.height * ((option.rate - baseRate) / maxRate))); + bars.push({ + rate: option.rate, + height: lastHeight, + class: 'max', + label: this.showEstimate ? $localize`maximum` : $localize`accelerated`, + active: option.index === this.maxRateIndex, + rateIndex: option.index, + fee: option.fee, + }) + }) + + bars.reverse(); + + baseHeight = this.height - lastHeight; + + for (const bar of bars) { + bar.style = this.getStyle(bar.height, baseHeight); + } + + bars.push({ + rate: baseRate, + style: this.getStyle(baseHeight, 0), + height: baseHeight, + class: 'tx', + label: '', + fee: this.estimate.txSummary.effectiveFee, + }); + + this.bars = bars; + this.cd.detectChanges(); + } + + getStyle(height: number, base: number): Record { + return { + height: `${height}px`, + bottom: base ? `${base}px` : '0', + } + } + + onClick(event, bar): void { + if (bar.rateIndex != null) { + this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex }); + } + } + + @HostListener('pointermove', ['$event']) + onPointerMove(event) { + this.tooltipPosition = { x: event.offsetX, y: event.offsetY }; + } + + startResizeFallbackLoop(): void { + if (this.stopResizeLoop) { + return; + } + requestAnimationFrame(() => { + this.height = this.container?.nativeElement?.clientHeight || 0; + this.initGraph(); + this.startResizeFallbackLoop(); + }); + } + + ngOnDestroy(): void { + this.stopResizeLoop = true; + this.observer.disconnect(); + } +} diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts deleted file mode 100644 index ebfa019a1..000000000 --- a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Component, OnInit, Input, Output, OnChanges, EventEmitter, HostListener, Inject, LOCALE_ID } from '@angular/core'; -import { StateService } from '../../services/state.service'; -import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; -import { Router } from '@angular/router'; -import { ReplaySubject, merge, Subscription, of } from 'rxjs'; -import { tap, switchMap } from 'rxjs/operators'; -import { ApiService } from '../../services/api.service'; -import { AccelerationEstimate, RateOption } from './accelerate-preview.component'; - -interface GraphBar { - rate: number; - style: any; - class: 'tx' | 'target' | 'max'; - label: string; - active?: boolean; - rateIndex?: number; - fee?: number; -} - -@Component({ - selector: 'app-accelerate-fee-graph', - templateUrl: './accelerate-fee-graph.component.html', - styleUrls: ['./accelerate-fee-graph.component.scss'], -}) -export class AccelerateFeeGraphComponent implements OnInit, OnChanges { - @Input() tx: Transaction; - @Input() estimate: AccelerationEstimate; - @Input() maxRateOptions: RateOption[] = []; - @Input() maxRateIndex: number = 0; - @Output() setUserBid = new EventEmitter<{ fee: number, index: number }>(); - - bars: GraphBar[] = []; - tooltipPosition = { x: 0, y: 0 }; - - ngOnInit(): void { - this.initGraph(); - } - - ngOnChanges(): void { - this.initGraph(); - } - - initGraph(): void { - if (!this.tx || !this.estimate) { - return; - } - const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate)); - const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize; - const baseHeight = baseRate / maxRate; - const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => { - return { - rate: option.rate, - style: this.getStyle(option.rate, maxRate, baseHeight), - class: 'max', - label: $localize`maximum`, - active: option.index === this.maxRateIndex, - rateIndex: option.index, - fee: option.fee, - } - }); - if (this.estimate.nextBlockFee > this.estimate.txSummary.effectiveFee) { - bars.push({ - rate: this.estimate.targetFeeRate, - style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight), - class: 'target', - label: $localize`:@@bdf0e930eb22431140a2eaeacd809cc5f8ebd38c:Next Block`.toLowerCase(), - fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee - }); - } - bars.push({ - rate: baseRate, - style: this.getStyle(baseRate, maxRate, 0), - class: 'tx', - label: '', - fee: this.estimate.txSummary.effectiveFee, - }); - this.bars = bars; - } - - getStyle(rate, maxRate, base) { - const top = (rate / maxRate); - return { - height: `${(top - base) * 100}%`, - bottom: base ? `${base * 100}%` : '0', - } - } - - onClick(event, bar): void { - if (bar.rateIndex != null) { - this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex }); - } - } - - @HostListener('pointermove', ['$event']) - onPointerMove(event) { - this.tooltipPosition = { x: event.offsetX, y: event.offsetY }; - } -} diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html deleted file mode 100644 index 3c8571dd4..000000000 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html +++ /dev/null @@ -1,258 +0,0 @@ - -
-
-
- Transaction has now been submitted to mining pools for acceleration. -
-
-
- - -
-
- -
-
- -
- - - - - -
- -
-
You are currently on the waitlist
-
- -
Your transaction
-
-
- - Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor(s) - - - - - - - - - - - - - - - - - - -
Virtual size
- Size in vbytes of this transaction and its unconfirmed ancestors -
In-band fees - {{ estimate.txSummary.effectiveFee | number : '1.0-0' }} sats -
- Fees already paid by this transaction and its unconfirmed ancestors -
-
-
-
-
How much more are you willing to pay?
-
-
- Choose the maximum extra transaction fee you're willing to pay to get into the next block. -
-
-
- - - -
-
-
-
-
- -
Acceleration summary
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Next block market rate - {{ estimate.targetFeeRate | number : '1.0-0' }} - sat/vB
- Estimated extra fee required - - {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} - - sats - -
Mempool Accelerator™ fees
- Accelerator Service Fee - - +{{ estimate.mempoolBaseFee | number }} - - sats - -
- Transaction Size Surcharge - - +{{ estimate.vsizeFee | number }} - - sats - -
- Estimated acceleration cost - - - {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} - - - sats - -
- -
- Maximum acceleration cost - - - {{ maxCost | number }} - - - sats - - - -
- -
Available balance - {{ estimate.userBalance | number }} - - sats - - - -
- Sign In -
- Accelerate on mempool.space -
-
-
- -
-
-
- -
-
-
- -
-
Accelerate with
-
-
- Loading -
-
-
- -
-
-
- - -
-
-
- -If your tx is accelerated to ~{{ i | number : '1.0-0' }} sat/vB \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss deleted file mode 100644 index 2e2b19ee8..000000000 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss +++ /dev/null @@ -1,112 +0,0 @@ -.fee-card { - padding: 15px; - background-color: #1d1f31; - - .feerate { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - .rate { - font-size: 0.9em; - .symbol { - color: white; - } - } - } -} - -.btn-border { - border: solid 1px black; - background-color: #0c4a87; -} - -.feerate.active { - background-color: #105fb0 !important; - opacity: 1; - border: 1px solid #007fff !important; -} -.feerate:focus { - box-shadow: none !important; -} - -.estimateDisabled { - opacity: 0.5; - pointer-events: none; -} - -.table-toggle { - width: 100%; - margin-top: 0.5em; -} - -.tab { - &:first-child { - margin-right: 1px; - } - border: solid 1px black; - border-bottom: none; - background-color: #323655; - border-top-left-radius: 10px !important; - border-top-right-radius: 10px !important; -} -.tab.active { - background-color: #5d659d !important; - opacity: 1; -} -.tab:focus { - box-shadow: none !important; -} - -.table-accelerator { - tr { - td { - padding-top: 0; - padding-bottom: 0; - vertical-align: baseline; - } - - &.group-first { - td { - padding-top: 0.75rem; - } - } - &.group-last { - td { - padding-bottom: 0.75rem; - } - } - } - td { - &:first-child { - width: 100vw; - } - &.info { - color: #6c757d; - white-space: initial; - } - &.amt { - text-align: right; - padding-right: 0.2em; - } - &.units { - padding-left: 0.2em; - white-space: nowrap; - display: flex; - justify-content: space-between; - align-items: center; - } - } -} - -.accelerate-cols { - display: flex; - flex-direction: row; - align-items: stretch; - margin-top: 1em; -} - -.item { - white-space: initial; -} \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts deleted file mode 100644 index aee0189aa..000000000 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener, ChangeDetectorRef } from '@angular/core'; -import { Subscription, catchError, of, tap } from 'rxjs'; -import { StorageService } from '../../services/storage.service'; -import { Transaction } from '../../interfaces/electrs.interface'; -import { nextRoundNumber } from '../../shared/common.utils'; -import { ServicesApiServices } from '../../services/services-api.service'; -import { AudioService } from '../../services/audio.service'; -import { StateService } from '../../services/state.service'; - -export type AccelerationEstimate = { - txSummary: TxSummary; - nextBlockFee: number; - targetFeeRate: number; - userBalance: number; - enoughBalance: boolean; - cost: number; - mempoolBaseFee: number; - vsizeFee: number; -} -export type TxSummary = { - txid: string; // txid of the current transaction - effectiveVsize: number; // Total vsize of the dependency tree - effectiveFee: number; // Total fee of the dependency tree in sats - ancestorCount: number; // Number of ancestors -} - -export interface RateOption { - fee: number; - rate: number; - index: number; -} - -export const MIN_BID_RATIO = 1; -export const DEFAULT_BID_RATIO = 2; -export const MAX_BID_RATIO = 4; - -@Component({ - selector: 'app-accelerate-preview', - templateUrl: 'accelerate-preview.component.html', - styleUrls: ['accelerate-preview.component.scss'] -}) -export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { - @Input() tx: Transaction | undefined; - @Input() scrollEvent: boolean; - - math = Math; - error = ''; - showSuccess = false; - estimateSubscription: Subscription; - accelerationSubscription: Subscription; - estimate: any; - hasAncestors: boolean = false; - minExtraCost = 0; - minBidAllowed = 0; - maxBidAllowed = 0; - defaultBid = 0; - maxCost = 0; - userBid = 0; - selectFeeRateIndex = 1; - isMobile: boolean = window.innerWidth <= 767.98; - user: any = undefined; - - maxRateOptions: RateOption[] = []; - - // Cashapp payment - paymentType: 'bitcoin' | 'cashapp' = 'bitcoin'; - cashAppSubscription: Subscription; - conversionsSubscription: Subscription; - payments: any; - showSpinner = false; - square: any; - cashAppPay: any; - hideCashApp = false; - - constructor( - public stateService: StateService, - private servicesApiService: ServicesApiServices, - private storageService: StorageService, - private audioService: AudioService, - private cd: ChangeDetectorRef - ) { - if (this.stateService.ref === 'https://cash.app/') { - this.insertSquare(); - } - } - - ngOnDestroy(): void { - if (this.estimateSubscription) { - this.estimateSubscription.unsubscribe(); - } - if (this.cashAppPay) { - this.cashAppPay.destroy(); - } - } - - ngOnInit() { - if (this.stateService.ref === 'https://cash.app/') { - this.paymentType = 'cashapp'; - this.stateService.ref = ''; - } else { - this.paymentType = 'bitcoin'; - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes.scrollEvent) { - this.scrollToPreview('acceleratePreviewAnchor', 'start'); - } - } - - ngAfterViewInit() { - if (this.paymentType === 'cashapp') { - this.showSpinner = true; - } - - this.user = this.storageService.getAuth()?.user ?? null; - - this.servicesApiService.setupSquare$().subscribe(ids => { - this.square = { - appId: ids.squareAppId, - locationId: ids.squareLocationId - }; - this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe( - tap((response) => { - if (response.status === 204) { - this.estimate = undefined; - this.error = `cannot_accelerate_tx`; - this.scrollToPreviewWithTimeout('mempoolError', 'center'); - this.estimateSubscription.unsubscribe(); - } else { - this.estimate = response.body; - if (!this.estimate) { - this.error = `cannot_accelerate_tx`; - this.scrollToPreviewWithTimeout('mempoolError', 'center'); - this.estimateSubscription.unsubscribe(); - } - - if (this.paymentType === 'cashapp') { - this.estimate.userBalance = 999999999; - this.estimate.enoughBalance = true; - } - - if (this.estimate.hasAccess === true && this.estimate.userBalance <= 0) { - if (this.isLoggedIn()) { - this.error = `not_enough_balance`; - this.scrollToPreviewWithTimeout('mempoolError', 'center'); - } - } - - this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; - - // Make min extra fee at least 50% of the current tx fee - this.minExtraCost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee)); - - this.maxRateOptions = [1, 2, 4].map((multiplier, index) => { - return { - fee: this.minExtraCost * multiplier, - rate: (this.estimate.txSummary.effectiveFee + (this.minExtraCost * multiplier)) / this.estimate.txSummary.effectiveVsize, - index, - }; - }); - - this.minBidAllowed = this.minExtraCost * MIN_BID_RATIO; - this.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO; - this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO; - - this.userBid = this.defaultBid; - if (this.userBid < this.minBidAllowed) { - this.userBid = this.minBidAllowed; - } else if (this.userBid > this.maxBidAllowed) { - this.userBid = this.maxBidAllowed; - } - this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; - - if (!this.error) { - this.scrollToPreview('acceleratePreviewAnchor', 'start'); - if (this.paymentType === 'cashapp') { - this.setupSquare(); - } - } - } - }), - catchError((response) => { - this.estimate = undefined; - this.error = response.error; - this.scrollToPreviewWithTimeout('mempoolError', 'center'); - this.estimateSubscription.unsubscribe(); - return of(null); - }) - ).subscribe(); - }); - } - - /** - * User changed his bid - */ - setUserBid({ fee, index }: { fee: number, index: number}) { - if (this.estimate) { - this.selectFeeRateIndex = index; - this.userBid = Math.max(0, fee); - this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; - } - } - - /** - * Scroll to element id with or without setTimeout - */ - scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition) { - setTimeout(() => { - this.scrollToPreview(id, position); - }, 100); - } - scrollToPreview(id: string, position: ScrollLogicalPosition) { - const acceleratePreviewAnchor = document.getElementById(id); - if (acceleratePreviewAnchor) { - this.cd.markForCheck(); - acceleratePreviewAnchor.scrollIntoView({ - behavior: 'smooth', - inline: position, - block: position, - }); - } - } - - /** - * Send acceleration request - */ - accelerate() { - if (this.accelerationSubscription) { - this.accelerationSubscription.unsubscribe(); - } - this.accelerationSubscription = this.servicesApiService.accelerate$( - this.tx.txid, - this.userBid - ).subscribe({ - next: () => { - this.audioService.playSound('ascend-chime-cartoon'); - this.showSuccess = true; - this.scrollToPreviewWithTimeout('successAlert', 'center'); - this.estimateSubscription.unsubscribe(); - }, - error: (response) => { - if (response.status === 403 && response.error === 'not_available') { - this.error = 'waitlisted'; - } else { - this.error = response.error; - } - this.scrollToPreviewWithTimeout('mempoolError', 'center'); - } - }); - } - - isLoggedIn() { - const auth = this.storageService.getAuth(); - return auth !== null; - } - - @HostListener('window:resize', ['$event']) - onResize(): void { - this.isMobile = window.innerWidth <= 767.98; - } - - /** - * CashApp payment - */ - setupSquare() { - const init = () => { - this.initSquare(); - }; - - //@ts-ignore - if (!window.Square) { - console.warn('Square.js failed to load properly. Retrying in 1 second.'); - setTimeout(init, 1000); - } else { - init(); - } - } - - async initSquare(): Promise { - try { - //@ts-ignore - this.payments = window.Square.payments(this.square.appId, this.square.locationId) - await this.requestCashAppPayment(); - } catch (e) { - console.error(e); - this.error = 'Error loading Square Payments'; - return; - } - } - - async requestCashAppPayment() { - if (this.cashAppSubscription) { - this.cashAppSubscription.unsubscribe(); - } - if (this.conversionsSubscription) { - this.conversionsSubscription.unsubscribe(); - } - this.hideCashApp = false; - - - this.conversionsSubscription = this.stateService.conversions$.subscribe( - async (conversions) => { - const maxCostUsd = this.maxCost / 100_000_000 * conversions.USD; - const paymentRequest = this.payments.paymentRequest({ - countryCode: 'US', - currencyCode: 'USD', - total: { - amount: maxCostUsd.toString(), - label: 'Total', - pending: true, - productUrl: `https://mempool.space/tx/${this.tx.txid}`, - } - }); - this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { - redirectURL: `https://mempool.space/tx/${this.tx.txid}`, - referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - }); - await this.cashAppPay.attach('#cash-app-pay'); - this.showSpinner = false; - - const that = this; - this.cashAppPay.addEventListener('ontokenization', function (event) { - const { tokenResult, error } = event.detail; - if (error) { - this.error = error; - } else if (tokenResult.status === 'OK') { - that.hideCashApp = true; - - that.accelerationSubscription = that.servicesApiService.accelerateWithCashApp$( - that.tx.txid, - that.userBid, - tokenResult.token, - tokenResult.details.cashAppPay.cashtag, - tokenResult.details.cashAppPay.referenceId - ).subscribe({ - next: () => { - that.audioService.playSound('ascend-chime-cartoon'); - that.showSuccess = true; - that.scrollToPreviewWithTimeout('successAlert', 'center'); - that.estimateSubscription.unsubscribe(); - }, - error: (response) => { - if (response.status === 403 && response.error === 'not_available') { - that.error = 'waitlisted'; - } else { - that.error = response.error; - } - that.scrollToPreviewWithTimeout('mempoolError', 'center'); - } - }); - } - }); - } - ); - } - - insertSquare(): void { - let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; - if (document.location.hostname === 'mempool-staging.tk7.mempool.space' || document.location.hostname === 'mempool.space') { - statsUrl = 'https://web.squarecdn.com/v1/square.js'; - } - - (function() { - const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - // @ts-ignore - g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s); - })(); - } -} diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.html b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.html new file mode 100644 index 000000000..67f6cb80e --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.html @@ -0,0 +1,62 @@ +
+ + + + + + + + + + + + + @if (accelerationInfo.status === 'accelerated') { + + } @else { + + } + + + @if (accelerationInfo.status === 'seen') { + + + } @else if (accelerationInfo.status === 'accelerated' || accelerationInfo.status === 'mined') { + + @if (accelerationInfo.status === 'accelerated') { + + } @else { + + } + } + + + + + + +
Status + @if (accelerationInfo.status === 'seen') { + First seen + } @else if (accelerationInfo.status === 'accelerated') { + Accelerated + } @else if (accelerationInfo.status === 'mined') { + Mined + } +
Fee{{ accelerationInfo.fee | number }} sat
Out-of-band fees{{ accelerationInfo.feeDelta | number }} sat{{ accelerationInfo.bidBoost | number }} sat
Fee rateAccelerated fee rate
Accelerated by + + + +
+
diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.scss b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.scss new file mode 100644 index 000000000..98a42f0e7 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.scss @@ -0,0 +1,39 @@ +.acceleration-tooltip { + position: fixed; + z-index: 3; + background: color-mix(in srgb, var(--active-bg) 95%, transparent); + border-radius: 4px; + box-shadow: 1px 1px 10px rgba(0,0,0,0.5); + color: var(--tooltip-grey); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 15px; + text-align: left; + pointer-events: none; + + .badge.badge-accelerated { + background-color: var(--tertiary); + color: white; + } + + .value { + text-align: end; + } + + .label { + padding-right: 30px; + } + + .pool-logo { + width: 22px; + height: 22px; + position: relative; + top: -1px; + margin-right: 3px; + } + + .oobFees { + color: #905cf4; + } +} diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.ts b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.ts new file mode 100644 index 000000000..b4b3405fc --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline-tooltip.component.ts @@ -0,0 +1,38 @@ +import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core'; + +@Component({ + selector: 'app-acceleration-timeline-tooltip', + templateUrl: './acceleration-timeline-tooltip.component.html', + styleUrls: ['./acceleration-timeline-tooltip.component.scss'], +}) +export class AccelerationTimelineTooltipComponent implements OnChanges { + @Input() accelerationInfo: any; + @Input() cursorPosition: { x: number, y: number }; + + tooltipPosition: any = null; + + @ViewChild('tooltip') tooltipElement: ElementRef; + + constructor() {} + + ngOnChanges(changes): void { + if (changes.cursorPosition && changes.cursorPosition.currentValue) { + let x = Math.max(10, changes.cursorPosition.currentValue.x - 50); + let y = changes.cursorPosition.currentValue.y + 20; + if (this.tooltipElement) { + const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); + if ((x + elementBounds.width) > (window.innerWidth - 10)) { + x = Math.max(0, window.innerWidth - elementBounds.width - 10); + } + if (y + elementBounds.height > (window.innerHeight - 20)) { + y = y - elementBounds.height - 20; + } + } + this.tooltipPosition = { x, y }; + } + } + + hasPoolsData(): boolean { + return Object.keys(this.accelerationInfo.poolsData).length > 0; + } +} diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html new file mode 100644 index 000000000..560e54629 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html @@ -0,0 +1,142 @@ +
+
+ @if (!tx.status.confirmed) { +
+
+
+
+
+
+
+ @if (eta) { + ~ + } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mined
+
+
+
+ } +
+
+
+
+
+ +
+
+
+
+
+ @if (tx.status.confirmed) { +
+ +
+ } @else if (standardETA && !tx.status.confirmed) { + + } +
+
+
+
+
+
+
+
+
+
+
First seen
+
+ @if (useAbsoluteTime) { + {{ transactionTime * 1000 | date }} + } @else { + + } +
+
+
+
+
+
+
+ @if (tx.status.confirmed) { +
+ } @else { +
+ } +
+
+ @if (!tx.status.confirmed) { +
+ } +
+ @if (tx.status.confirmed) { +
Accelerated
+ } +
+ @if (!tx.status.confirmed) { + Accelerated{{ "" }} + } + @if (useAbsoluteTime) { + {{ acceleratedAt * 1000 | date }} + } @else { + + } +
+
+
+ @if (tx.status.confirmed) { +
+ } @else { +
+ } +
+
+ @if (tx.status.confirmed) { +
+ } @else { +
+ } +
+
+
+ @if (tx.status.confirmed) { +
Mined
+
+ @if (useAbsoluteTime) { + {{ tx.status.block_time * 1000 | date }} + } @else { + + } +
+ } +
+
+
+
+ + + +
diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss new file mode 100644 index 000000000..f351a0114 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss @@ -0,0 +1,272 @@ +.acceleration-timeline { + position: relative; + width: 100%; + padding: 1em 0; + &.lower-padding { + padding: 0.5em 0 1em; + } + + &::after, &::before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 2em; + z-index: 2; + } + + &::before { + left: 0; + background: linear-gradient(to right, var(--box-bg), var(--box-bg), transparent); + } + + &::after { + right: 0; + background: linear-gradient(to left, var(--box-bg), var(--box-bg), transparent); + } + + .timeline-wrapper { + position: relative; + width: calc(100% - 2em); + margin: auto; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .intervals, .nodes { + min-width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + text-align: center; + + .node, .node-spacer { + width: 6em; + min-width: 6em; + flex-grow: 1; + } + + .interval, .interval-spacer { + width: 8em; + min-width: 8em; + max-width: 8em; + height: 32px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-end; + } + + .interval { + overflow: visible; + } + + .interval-time { + font-size: 12px; + line-height: 16px; + white-space: nowrap; + + .compare { + font-style: italic; + color: var(--mainnet-alt); + font-weight: 600; + @media (max-width: 600px) { + display: none; + } + } + } + } + + .node, .interval-spacer { + position: relative; + .seen-to-acc { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: var(--primary); + border-radius: 5px; + + &.left { + right: 50%; + } + + &.right { + left: 50%; + } + } + + .acc-to-confirmed { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: var(--tertiary); + border-radius: 5px; + + &.go-faster { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='10'%3E%3Cpath style='fill:%239339f4;' d='M 0,0 5,5 0,10 Z'/%3E%3Cpath style='fill:%23653b9c;' d='M 0,0 10,0 15,5 10,10 0,10 5,5 Z'/%3E%3Cpath style='fill:%239339f4;' d='M 10,0 20,0 20,10 10,10 15,5 Z'/%3E%3C/svg%3E%0A"); background-size: 20px 10px; + border-radius: 0; + + &.right { + left: calc(50% + 5px); + margin-right: calc(-4em + 5px); + animation: goFasterRight 0.8s infinite linear; + } + &.left { + right: calc(50% + 5px); + margin-left: calc(-4em + 5px); + animation: goFasterLeft 0.8s infinite linear; + } + } + + &.left { + right: 50%; + } + &.right { + left: 50%; + } + } + } + + .nodes { + position: relative; + margin-top: 1em; + .node { + .shape-border { + display: block; + margin: auto; + height: calc(1em + 8px); + width: calc(1em + 8px); + margin-bottom: -8px; + transform: translateY(-50%); + border-radius: 50%; + padding: 4px; + background: transparent; + transition: background-color 300ms, padding 300ms; + + &.hovering { + cursor: pointer; + &:hover { + padding: 0px; + } + } + + .shape { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: white; + z-index: 1; + } + + &.waiting { + .shape { + background: var(--grey); + } + } + + .connector { + position: absolute; + z-index: 0; + height: 88px; + width: 10px; + left: -5px; + top: -73px; + transform: translateX(120%); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='20'%3E%3Cpath style='fill:%239339f4;' d='M 0,20 5,15 10,20 Z'/%3E%3Cpath style='fill:%23653b9c;' d='M 0,20 5,15 10,20 10,10 5,5 0,10 Z'/%3E%3Cpath style='fill:%239339f4;' d='M 0,10 5,5 10,10 10,0 0,0 Z'/%3E%3C/svg%3E%0A"); // linear-gradient(135deg, var(--tertiary) 34%, transparent 34%), + background-size: 10px 20px; + + &.down { + border-top-left-radius: 10px; + } + + &.up { + border-top-right-radius: 10px; + } + + &.loading { + animation: goFasterUp 0.8s infinite linear; + } + } + } + + &.accelerated { + .shape-border { + animation: acceleratePulse 0.4s infinite; + } + } + + &.selected { + .shape-border { + background: var(--mainnet-alt); + } + } + + .status { + margin-top: -66px; + + .badge.badge-waiting { + opacity: 0.5; + background-color: var(--grey); + color: white; + } + + .badge.badge-accelerated { + background-color: var(--tertiary); + color: white; + } + } + + .time { + margin-top: 32px; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + + &.offset-left { + @media (max-width: 650px) { + margin-left: -20px; + } + } + + &.no-margin { + margin-top: 0px; + } + } + } + } +} + +@keyframes acceleratePulse { + 0% { background-color: var(--tertiary) } + 50% { background-color: var(--mainnet-alt) } + 100% { background-color: var(--tertiary) } +} + +@keyframes goFasterUp { + 0% { background-position-y: 0; } + 100% { background-position-y: -40px; } +} + +@keyframes goFasterLeft { + 0% { background-position: left 0px bottom 0px } + 100% { background-position: left 40px bottom 0px; } +} + +@keyframes goFasterRight { + 0% { background-position: right 0 bottom 0px; } + 100% { background-position: right -40px bottom 0px; } +} diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts new file mode 100644 index 000000000..da0eee4a3 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, OnInit, OnChanges, HostListener } from '@angular/core'; +import { ETA } from '../../services/eta.service'; +import { Transaction } from '../../interfaces/electrs.interface'; +import { Acceleration, SinglePoolStats } from '../../interfaces/node-api.interface'; +import { MiningService } from '../../services/mining.service'; + +@Component({ + selector: 'app-acceleration-timeline', + templateUrl: './acceleration-timeline.component.html', + styleUrls: ['./acceleration-timeline.component.scss'], +}) +export class AccelerationTimelineComponent implements OnInit, OnChanges { + @Input() transactionTime: number; + @Input() tx: Transaction; + @Input() accelerationInfo: Acceleration; + @Input() eta: ETA; + // A mined transaction has standard ETA and accelerated ETA undefined + // A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet) + @Input() standardETA: number; + @Input() acceleratedETA: number; + + acceleratedAt: number; + now: number; + accelerateRatio: number; + useAbsoluteTime: boolean = false; + interval: number; + + tooltipPosition = null; + hoverInfo: any = null; + poolsData: { [id: number]: SinglePoolStats } = {}; + + constructor( + private miningService: MiningService, + ) {} + + ngOnInit(): void { + this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000; + this.now = Math.floor(new Date().getTime() / 1000); + this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600; + + this.miningService.getPools().subscribe(pools => { + for (const pool of pools) { + this.poolsData[pool.unique_id] = pool; + } + }); + + this.interval = window.setInterval(() => { + this.now = Math.floor(new Date().getTime() / 1000); + this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600; + }, 60000); + } + + ngOnChanges(changes): void { + // Hide standard ETA while we don't have a proper standard ETA calculation, see https://github.com/mempool/mempool/issues/65 + + // if (changes?.eta?.currentValue || changes?.standardETA?.currentValue || changes?.acceleratedETA?.currentValue) { + // if (changes?.eta?.currentValue) { + // if (changes?.acceleratedETA?.currentValue) { + // this.accelerateRatio = Math.floor((Math.floor(changes.eta.currentValue.time / 1000) - this.now) / (Math.floor(changes.acceleratedETA.currentValue / 1000) - this.now)); + // } else if (changes?.standardETA?.currentValue) { + // this.accelerateRatio = Math.floor((Math.floor(changes.standardETA.currentValue / 1000) - this.now) / (Math.floor(changes.eta.currentValue.time / 1000) - this.now)); + // } + // } + // } + } + + ngOnDestroy(): void { + clearInterval(this.interval); + } + + onHover(event, status: string): void { + if (status === 'seen') { + this.hoverInfo = { + status, + fee: this.tx.fee, + weight: this.tx.weight + }; + } else if (status === 'accelerated') { + this.hoverInfo = { + status, + fee: this.accelerationInfo?.effectiveFee || this.tx.fee, + weight: this.tx.weight, + feeDelta: this.accelerationInfo?.feeDelta || this.tx.feeDelta, + pools: this.tx.acceleratedBy || this.accelerationInfo?.pools, + poolsData: this.poolsData + }; + } else if (status === 'mined') { + this.hoverInfo = { + status, + fee: this.accelerationInfo?.effectiveFee, + weight: this.tx.weight, + bidBoost: this.accelerationInfo?.bidBoost, + minedByPoolUniqueId: this.accelerationInfo?.minedByPoolUniqueId, + pools: this.tx.acceleratedBy || this.accelerationInfo?.pools, + poolsData: this.poolsData + }; + } + } + + onBlur(event): void { + this.hoverInfo = null; + } + + @HostListener('pointermove', ['$event']) + onPointerMove(event) { + this.tooltipPosition = { x: event.clientX, y: event.clientY }; + } +} diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html index cbebb0f86..9146c8e34 100644 --- a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.html @@ -10,7 +10,7 @@
-
+
@@ -45,7 +45,7 @@
-
diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss index 11b468a24..0b7c170fc 100644 --- a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -62,10 +63,5 @@ h5 { .card-title { font-size: 1rem; - color: #4a68b9; + color: var(--title-fg); } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts index 91de5ca03..d78b663a4 100644 --- a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; import { EChartsOption } from '../../../graphs/echarts'; -import { Observable, Subscription, combineLatest, fromEvent, share } from 'rxjs'; +import { Observable, Subject, Subscription, combineLatest, fromEvent, merge, share } from 'rxjs'; import { startWith, switchMap, tap } from 'rxjs/operators'; import { SeoService } from '../../../services/seo.service'; import { formatNumber } from '@angular/common'; @@ -8,10 +8,11 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../../shared/graphs.utils'; import { StorageService } from '../../../services/storage.service'; import { MiningService } from '../../../services/mining.service'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Acceleration } from '../../../interfaces/node-api.interface'; import { ServicesApiServices } from '../../../services/services-api.service'; import { StateService } from '../../../services/state.service'; +import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe'; @Component({ selector: 'app-acceleration-fees-graph', @@ -22,16 +23,17 @@ import { StateService } from '../../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { +export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDestroy { @Input() widget: boolean = false; @Input() height: number = 300; @Input() right: number | string = 45; @Input() left: number | string = 75; + @Input() period: '24h' | '3d' | '1w' | '1m' | 'all' = '1w'; @Input() accelerations$: Observable; miningWindowPreference: string; @@ -47,9 +49,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { isLoading = true; formatNumber = formatNumber; timespan = ''; + periodSubject$: Subject<'24h' | '3d' | '1w' | '1m' | 'all'> = new Subject(); chartInstance: any = undefined; - - currency: string; daysAvailable: number = 0; constructor( @@ -62,30 +63,35 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { private route: ActivatedRoute, public stateService: StateService, private cd: ChangeDetectorRef, + private router: Router, + private zone: NgZone, ) { - this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); - this.radioGroupForm.controls.dateSpan.setValue('1y'); - this.currency = 'USD'; + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' }); + this.radioGroupForm.controls.dateSpan.setValue('1w'); } ngOnInit(): void { if (this.widget) { - this.miningWindowPreference = '3m'; + this.miningWindowPreference = this.period; } else { this.seoService.setTitle($localize`:@@bcf34abc2d9ed8f45a2f65dd464c46694e9a181e:Acceleration Fees`); - this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); + this.miningWindowPreference = this.miningService.getDefaultTimespan('1w'); } this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); this.route.fragment.subscribe((fragment) => { - if (['24h', '3d', '1w', '1m', '3m'].indexOf(fragment) > -1) { + if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) { this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); } }); this.aggregatedHistory$ = combineLatest([ - this.radioGroupForm.get('dateSpan').valueChanges.pipe( - startWith(this.radioGroupForm.controls.dateSpan.value), + merge( + this.radioGroupForm.get('dateSpan').valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + ), + this.periodSubject$ + ).pipe( switchMap((timespan) => { if (!this.widget) { this.storageService.setValue('miningWindowPreference', timespan); @@ -110,6 +116,12 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { this.aggregatedHistory$.subscribe(); } + ngOnChanges(changes: SimpleChanges): void { + if (changes.period) { + this.periodSubject$.next(this.period); + } + } + prepareChartOptions(data) { let title: object; if (data.length === 0) { @@ -221,7 +233,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -285,6 +297,19 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { onChartInit(ec) { this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + this.zone.run(() => { + if (['24h', '3d'].includes(this.timespan)) { + const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.data[2]}`); + if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { + window.open(url); + } else { + this.router.navigate([url]); + } + } + }); + }); } isMobile() { diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html index fef91acc0..c3c0e5134 100644 --- a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.html @@ -4,7 +4,7 @@
Requests
{{ stats.totalRequested }}
-
accelerated
+
accelerated
@@ -17,10 +17,10 @@
-
Success Rate
+
Total vSize
-
{{ stats.successRate.toFixed(2) }} %
-
mined
+
+
{{ (stats.totalVsize / (1_000_000 * blocksInPeriod) * 100).toFixed(2) }}% of blocks
@@ -43,7 +43,7 @@
-
Success Rate
+
Total vSize
diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss index fcc5564a8..733116776 100644 --- a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.scss @@ -1,5 +1,5 @@ .card-title { - color: #4a68b9; + color: var(--title-fg); font-size: 10px; margin-bottom: 4px; font-size: 1rem; @@ -34,7 +34,7 @@ @media (min-width: 376px) { margin: 0 auto 0px; } - &:first-child{ + &:last-child{ display: none; @media (min-width: 485px) { display: block; @@ -50,7 +50,7 @@ margin-bottom: 0; } .card-text span { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; top: 0px; } diff --git a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts index 29909ff39..392f1392b 100644 --- a/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts +++ b/frontend/src/app/components/acceleration/acceleration-stats/acceleration-stats.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ServicesApiServices } from '../../../services/services-api.service'; @@ -6,6 +6,7 @@ export type AccelerationStats = { totalRequested: number; totalBidBoost: number; successRate: number; + totalVsize: number; } @Component({ @@ -14,14 +15,41 @@ export type AccelerationStats = { styleUrls: ['./acceleration-stats.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccelerationStatsComponent implements OnInit { +export class AccelerationStatsComponent implements OnInit, OnChanges { + @Input() timespan: '24h' | '3d' | '1w' | '1m' | 'all' = '1w'; accelerationStats$: Observable; + blocksInPeriod: number = 7 * 144; constructor( private servicesApiService: ServicesApiServices ) { } ngOnInit(): void { - this.accelerationStats$ = this.servicesApiService.getAccelerationStats$(); + this.updateStats(); + } + + ngOnChanges(): void { + this.updateStats(); + } + + updateStats(): void { + this.accelerationStats$ = this.servicesApiService.getAccelerationStats$({ timeframe: this.timespan }); + switch (this.timespan) { + case '24h': + this.blocksInPeriod = 144; + break; + case '3d': + this.blocksInPeriod = 3 * 144; + break; + case '1w': + this.blocksInPeriod = 7 * 144; + break; + case '1m': + this.blocksInPeriod = 30 * 144; + break; + case 'all': + this.blocksInPeriod = Infinity; + break; + } } } diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html index 74c8ed3d1..8bdd4f14d 100644 --- a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html @@ -4,23 +4,24 @@
-
- +
+
- - - + + + - + + - + - + - - + - - - - - - - - + @if (!pending) { + + + + + + + + + + + } @else { + + + + + + + + + }
TXID Fee RateAcceleration BidRequestedFee rateBidRequested Bid BoostBlockBlockPool StatusRequestedRequested
@@ -35,48 +36,84 @@ {{ (acceleration.feeDelta) | number }} sat - + - {{ (acceleration.boost) | number }} sat + + {{ acceleration.boost | number }} sat + ~ {{ acceleration.blockHeight }} ~ + @if (acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]) { + + + {{ pools[acceleration.minedByPoolUniqueId].name }} + + } @else { + ~ + } + Pending - Completed 🔄 - Failed 🔄 + Completed ⌛ + Mined ⌛ + Failed ⌛ - +
- - - - - - - -
+ + + + + + + + + + + +
+ + + + + + + +
diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss index 85e655b25..7f7f24bd5 100644 --- a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.scss @@ -12,7 +12,7 @@ padding-bottom: 0px; } .container-xl.legacy { - max-width: 1140px; + max-width: 1200px; } .container-xl.widget-container { min-height: 335px; @@ -59,7 +59,7 @@ tr, td, th { } .progress { - background-color: #2d3348; + background-color: var(--secondary); } .txid { @@ -72,9 +72,25 @@ tr, td, th { .block { width: 15%; + @media (max-width: 900px) { + display: none; + } +} + +.pool { + width: 15%; + @media (max-width: 700px) { display: none; } + + .pool-logo { + width: 18px; + height: 18px; + position: relative; + top: -1px; + margin-right: 2px; + } } .status { @@ -148,7 +164,7 @@ tr, td, th { .tooltip-custom .tooltiptext { visibility: hidden; - color: #fff; + color: var(--fg); text-align: center; padding: 5px 0; border-radius: 6px; diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts index 1a0aacbb6..e45a983e1 100644 --- a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts @@ -1,9 +1,12 @@ -import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; -import { combineLatest, BehaviorSubject, Observable, catchError, of, switchMap, tap } from 'rxjs'; -import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; +import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Inject, LOCALE_ID } from '@angular/core'; +import { BehaviorSubject, Observable, Subscription, catchError, filter, of, switchMap, tap, throttleTime } from 'rxjs'; +import { Acceleration, BlockExtended, SinglePoolStats } from '../../../interfaces/node-api.interface'; import { StateService } from '../../../services/state.service'; import { WebsocketService } from '../../../services/websocket.service'; import { ServicesApiServices } from '../../../services/services-api.service'; +import { SeoService } from '../../../services/seo.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MiningService } from '../../../services/mining.service'; @Component({ selector: 'app-accelerations-list', @@ -11,7 +14,7 @@ import { ServicesApiServices } from '../../../services/services-api.service'; styleUrls: ['./accelerations-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccelerationsListComponent implements OnInit { +export class AccelerationsListComponent implements OnInit, OnDestroy { @Input() widget: boolean = false; @Input() pending: boolean = false; @Input() accelerations$: Observable; @@ -25,26 +28,79 @@ export class AccelerationsListComponent implements OnInit { maxSize = window.innerWidth <= 767.98 ? 3 : 5; skeletonLines: number[] = []; pageSubject: BehaviorSubject = new BehaviorSubject(this.page); + keyNavigationSubscription: Subscription; + dir: 'rtl' | 'ltr' = 'ltr'; + paramSubscription: Subscription; + pools: { [id: number]: SinglePoolStats } = {}; + nonEmptyAccelerations: boolean = true; constructor( private servicesApiService: ServicesApiServices, private websocketService: WebsocketService, public stateService: StateService, + private miningService: MiningService, private cd: ChangeDetectorRef, + private seoService: SeoService, + private route: ActivatedRoute, + private router: Router, + @Inject(LOCALE_ID) private locale: string, ) { + if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { + this.dir = 'rtl'; + } } ngOnInit(): void { + this.miningService.getPools().subscribe(pools => { + for (const pool of pools) { + this.pools[pool.unique_id] = pool; + } + }); + if (!this.widget) { this.websocketService.want(['blocks']); + this.seoService.setTitle($localize`:@@02573b6980a2d611b4361a2595a4447e390058cd:Accelerations`); + + this.paramSubscription = this.route.params.pipe( + tap(params => { + this.page = +params['page'] || 1; + this.pageSubject.next(this.page); + }) + ).subscribe(); + + const prevKey = this.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'; + const nextKey = this.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'; + + this.keyNavigationSubscription = this.stateService.keyNavigation$.pipe( + filter((event) => event.key === prevKey || event.key === nextKey), + tap((event) => { + if (event.key === prevKey && this.page > 1) { + this.page--; + this.isLoading = true; + this.cd.markForCheck(); + } + if (event.key === nextKey && this.page * 15 < this.accelerationCount) { + this.page++; + this.isLoading = true; + this.cd.markForCheck(); + } + }), + throttleTime(1000, undefined, { leading: true, trailing: true }), + ).subscribe(() => { + this.pageChange(this.page); + }); } this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; - + this.accelerationList$ = this.pageSubject.pipe( switchMap((page) => { - const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistoryObserveResponse$({ page: page })); + this.isLoading = true; + const accelerationObservable$ = this.accelerations$ || (this.pending ? this.stateService.liveAccelerations$ : this.servicesApiService.getAccelerationHistoryObserveResponse$({ page: page })); + if (!this.accelerations$ && this.pending) { + this.websocketService.ensureTrackAccelerations(); + } return accelerationObservable$.pipe( switchMap(response => { let accelerations = response; @@ -58,8 +114,9 @@ export class AccelerationsListComponent implements OnInit { } } for (const acc of accelerations) { - acc.boost = acc.feePaid - acc.baseFee - acc.vsizeFee; + acc.boost = acc.boostCost != null ? acc.boostCost : acc.bidBoost; } + this.nonEmptyAccelerations = accelerations.length > 0; if (this.widget) { return of(accelerations.slice(0, 6)); } else { @@ -79,10 +136,16 @@ export class AccelerationsListComponent implements OnInit { } pageChange(page: number): void { - this.pageSubject.next(page); + this.router.navigate(['acceleration', 'list', page]); } trackByBlock(index: number, block: BlockExtended): number { return block.height; } + + ngOnDestroy(): void { + this.websocketService.stopTrackAccelerations(); + this.paramSubscription?.unsubscribe(); + this.keyNavigationSubscription?.unsubscribe(); + } } \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html index 3488e1075..9095a8129 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.html @@ -22,12 +22,38 @@
Acceleration stats  - (3 months) + @switch (timespan) { + @case ('24h') { + (1 day) + } + @case ('1w') { + (1 week) + } + @case ('1m') { + (1 month) + } + @case ('all') { + (all time) + } + }
- + +
+ 24h + | + 1w + | + 1m + | + all +
@@ -40,7 +66,7 @@
Mempool Goggles™ : Accelerations
  - +
@@ -59,6 +85,7 @@ [height]="graphHeight" [attr.data-cy]="'acceleration-fees'" [widget]=true + [period]="timespan" >
@@ -85,7 +112,7 @@
Recent Accelerations
  - +
diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss index c8755c94e..e6763f60a 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss @@ -7,7 +7,7 @@ } .card { - background-color: #1d1f31; + background-color: var(--bg); } .graph-card { @@ -29,10 +29,10 @@ .card-title { font-size: 1rem; - color: #4a68b9; + color: var(--title-fg); } .card-title > a { - color: #4a68b9; + color: var(--title-fg); } .card-body.pool-ranking { @@ -60,7 +60,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -172,4 +173,20 @@ max-height: 430px; max-width: 430px; } +} + +.widget-toggler { + font-size: 12px; + position: absolute; + top: -20px; + right: 3px; + text-align: right; +} + +.toggler-option { + text-decoration: none; +} + +.inactive { + color: #ffffff66; } \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts index 58bc43c42..d84c6e97c 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts @@ -1,20 +1,22 @@ -import { ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, PLATFORM_ID } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { SeoService } from '../../../services/seo.service'; import { OpenGraphService } from '../../../services/opengraph.service'; import { WebsocketService } from '../../../services/websocket.service'; import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { StateService } from '../../../services/state.service'; -import { Observable, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; +import { Observable, Subscription, catchError, combineLatest, distinctUntilChanged, map, of, share, switchMap, tap } from 'rxjs'; import { Color } from '../../block-overview-graph/sprite-types'; import { hexToColor } from '../../block-overview-graph/utils'; import TxView from '../../block-overview-graph/tx-view'; -import { feeLevels, mempoolFeeColors } from '../../../app.constants'; +import { feeLevels, defaultMempoolFeeColors, contrastMempoolFeeColors } from '../../../app.constants'; import { ServicesApiServices } from '../../../services/services-api.service'; import { detectWebGL } from '../../../shared/graphs.utils'; import { AudioService } from '../../../services/audio.service'; +import { ThemeService } from '../../../services/theme.service'; const acceleratedColor: Color = hexToColor('8F5FF6'); -const normalColors = mempoolFeeColors.map(hex => hexToColor(hex.slice(0,6) + '5F')); +const normalColors = defaultMempoolFeeColors.map(hex => hexToColor(hex + '5F')); +const contrastColors = contrastMempoolFeeColors.map(hex => hexToColor(hex.slice(0,6) + '5F')); interface AccelerationBlock extends BlockExtended { accelerationCount: number, @@ -26,7 +28,7 @@ interface AccelerationBlock extends BlockExtended { styleUrls: ['./accelerator-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AcceleratorDashboardComponent implements OnInit { +export class AcceleratorDashboardComponent implements OnInit, OnDestroy { blocks$: Observable; accelerations$: Observable; pendingAccelerations$: Observable; @@ -35,8 +37,12 @@ export class AcceleratorDashboardComponent implements OnInit { webGlEnabled = true; seen: Set = new Set(); firstLoad = true; + timespan: '24h' | '3d' | '1w' | '1m' | 'all' = '1w'; + + accelerationDeltaSubscription: Subscription; graphHeight: number = 300; + theme: ThemeService; constructor( private seoService: SeoService, @@ -55,27 +61,28 @@ export class AcceleratorDashboardComponent implements OnInit { ngOnInit(): void { this.onResize(); this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); + this.websocketService.startTrackAccelerations(); - this.pendingAccelerations$ = (this.stateService.isBrowser ? interval(30000) : of(null)).pipe( - startWith(true), - switchMap(() => { - return this.serviceApiServices.getAccelerations$().pipe( - catchError(() => { - return of([]); - }), - ); - }), - tap(accelerations => { - if (!this.firstLoad && accelerations.some(acc => !this.seen.has(acc.txid))) { - this.audioService.playSound('bright-harmony'); - } - for(const acc of accelerations) { - this.seen.add(acc.txid); - } - this.firstLoad = false; - }), + this.pendingAccelerations$ = this.stateService.liveAccelerations$.pipe( share(), ); + this.accelerationDeltaSubscription = this.stateService.accelerations$.subscribe((delta) => { + if (!delta.reset) { + let hasNewAcceleration = false; + for (const acc of delta.added) { + if (!this.seen.has(acc.txid)) { + hasNewAcceleration = true; + } + this.seen.add(acc.txid); + } + for (const txid of delta.removed) { + this.seen.delete(txid); + } + if (hasNewAcceleration) { + this.audioService.playSound('bright-harmony'); + } + } + }); this.accelerations$ = this.stateService.chainTip$.pipe( distinctUntilChanged(), @@ -89,10 +96,16 @@ export class AcceleratorDashboardComponent implements OnInit { share(), ); - this.minedAccelerations$ = this.accelerations$.pipe( - map(accelerations => { - return accelerations.filter(acc => ['completed_provisional', 'completed'].includes(acc.status)); - }) + this.minedAccelerations$ = this.stateService.chainTip$.pipe( + distinctUntilChanged(), + switchMap(() => { + return this.serviceApiServices.getAccelerationHistory$({ status: 'completed_provisional,completed', pageLength: 6 }).pipe( + catchError(() => { + return of([]); + }), + ); + }), + share(), ); this.blocks$ = combineLatest([ @@ -116,15 +129,15 @@ export class AcceleratorDashboardComponent implements OnInit { switchMap(([accelerations, blocks]) => { const blockMap = {}; for (const block of blocks) { - blockMap[block.id] = block; + blockMap[block.height] = block; } - const accelerationsByBlock: { [ hash: string ]: Acceleration[] } = {}; + const accelerationsByBlock: { [ height: number ]: Acceleration[] } = {}; for (const acceleration of accelerations) { - if (['completed_provisional', 'failed_provisional', 'completed'].includes(acceleration.status) && acceleration.pools.includes(blockMap[acceleration.blockHash]?.extras.pool.id)) { - if (!accelerationsByBlock[acceleration.blockHash]) { - accelerationsByBlock[acceleration.blockHash] = []; + if (['completed_provisional', 'failed_provisional', 'completed'].includes(acceleration.status) && acceleration.pools.includes(blockMap[acceleration.blockHeight]?.extras.pool.id)) { + if (!accelerationsByBlock[acceleration.blockHeight]) { + accelerationsByBlock[acceleration.blockHeight] = []; } - accelerationsByBlock[acceleration.blockHash].push(acceleration); + accelerationsByBlock[acceleration.blockHeight].push(acceleration); } } return of(blocks.slice(0, 6).map(block => { @@ -141,10 +154,20 @@ export class AcceleratorDashboardComponent implements OnInit { } else { const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; - return normalColors[feeLevelIndex] || normalColors[mempoolFeeColors.length - 1]; + return this.theme.theme === 'contrast' || this.theme.theme === 'bukele' ? contrastColors[feeLevelIndex] || contrastColors[contrastColors.length - 1] : normalColors[feeLevelIndex] || normalColors[normalColors.length - 1]; } } + setTimespan(timespan): boolean { + this.timespan = timespan; + return false; + } + + ngOnDestroy(): void { + this.accelerationDeltaSubscription.unsubscribe(); + this.websocketService.stopTrackAccelerations(); + } + @HostListener('window:resize', ['$event']) onResize(): void { if (window.innerWidth >= 992) { diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html new file mode 100644 index 000000000..13d38443e --- /dev/null +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html @@ -0,0 +1,66 @@ +@if (chartOnly) { + +} @else { + + + + + + + + + + + + + @if (hasCpfp && chartPositionLeft) { + + + + } + +
Accelerated to + + +
+ @if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) { + + } @else { + + } +
+
+
+ @if (hasCpfp) { + + } + +
+
Accelerated by + {{ acceleratedByPercentage }} of hashrate +
+
+ +
+
+} + + +
+ @if (chartOptions && miningStats) { +
+ } @else { +
+
+
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.scss b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.scss new file mode 100644 index 000000000..041a66ea2 --- /dev/null +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.scss @@ -0,0 +1,68 @@ +.td-width { + width: 150px; + min-width: 150px; + + @media (max-width: 768px) { + width: 175px; + min-width: 175px; + } +} + +.field-label { + @media (max-width: 849px) { + text-align: left; + } + @media (max-width: 649px) { + width: auto; + min-width: auto; + } + &.chart-left { + width: 100%; + } +} + +.field-value { + @media (max-width: 849px) { + width: 100%; + } + + &.chart-left { + width: auto; + } + + .hashrate-label { + @media (max-width: 420px) { + display: none; + } + } +} + +.pie-chart { + width: 100%; + vertical-align: middle; + text-align: center; + + .chart-container { + width: 72px; + height: 100%; + margin-left: auto; + } + + @media (max-width: 850px) { + width: 150px; + } + @media (max-width: 420px) { + padding-left: 0; + } +} + +::ng-deep .chart { + overflow: visible; + & > div, & > div > svg { + overflow: visible !important; + } +} + +.oobFees { + color: #905cf4; +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts new file mode 100644 index 000000000..f95bb71c8 --- /dev/null +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts @@ -0,0 +1,147 @@ +import { Component, ChangeDetectionStrategy, Input, Output, OnChanges, SimpleChanges, EventEmitter } from '@angular/core'; +import { Transaction } from '../../../interfaces/electrs.interface'; +import { Acceleration, SinglePoolStats } from '../../../interfaces/node-api.interface'; +import { EChartsOption, PieSeriesOption } from '../../../graphs/echarts'; +import { MiningStats } from '../../../services/mining.service'; + +function lighten(color, p): { r, g, b } { + return { + r: color.r + ((255 - color.r) * p), + g: color.g + ((255 - color.g) * p), + b: color.b + ((255 - color.b) * p), + }; +} + +function toRGB({r,g,b}): string { + return `rgb(${r},${g},${b})`; +} + +@Component({ + selector: 'app-active-acceleration-box', + templateUrl: './active-acceleration-box.component.html', + styleUrls: ['./active-acceleration-box.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ActiveAccelerationBox implements OnChanges { + @Input() tx: Transaction; + @Input() accelerationInfo: Acceleration; + @Input() miningStats: MiningStats; + @Input() pools: number[]; + @Input() hasCpfp: boolean = false; + @Input() chartOnly: boolean = false; + @Input() chartPositionLeft: boolean = false; + @Output() toggleCpfp = new EventEmitter(); + + acceleratedByPercentage: string = ''; + + chartOptions: EChartsOption; + chartInitOptions = { + renderer: 'svg', + }; + timespan = ''; + chartInstance: any = undefined; + + constructor() {} + + ngOnChanges(changes: SimpleChanges): void { + const pools = this.pools || this.accelerationInfo?.pools || this.tx.acceleratedBy; + if (pools && this.miningStats) { + this.prepareChartOptions(pools); + } + } + + getChartData(poolList: number[]) { + const data: object[] = []; + const pools: { [id: number]: SinglePoolStats } = {}; + for (const pool of this.miningStats.pools) { + pools[pool.poolUniqueId] = pool; + } + + const getDataItem = (value, color, tooltip, emphasis) => ({ + value, + name: tooltip, + itemStyle: { + color, + }, + }); + + const acceleratingPools = (poolList || []).filter(id => pools[id]).sort((a,b) => pools[a].lastEstimatedHashrate - pools[b].lastEstimatedHashrate); + const totalAcceleratedHashrate = acceleratingPools.reduce((total, pool) => total + pools[pool].lastEstimatedHashrate, 0); + // Find the first pool with at least 1% of the total network hashrate + const firstSignificantPool = acceleratingPools.findIndex(pool => pools[pool].lastEstimatedHashrate > this.miningStats.lastEstimatedHashrate / 100); + const numSignificantPools = acceleratingPools.length - firstSignificantPool; + acceleratingPools.forEach((poolId, index) => { + const pool = pools[poolId]; + const poolShare = ((pool.lastEstimatedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1); + data.push(getDataItem( + pool.lastEstimatedHashrate, + index >= firstSignificantPool + ? toRGB(lighten({ r: 147, g: 57, b: 244 }, 1 - (index - firstSignificantPool) / (numSignificantPools - 1))) + : 'white', + `${pool.name} (${poolShare}%)`, + true, + ) as PieSeriesOption); + }) + this.acceleratedByPercentage = ((totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1) + '%'; + const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%'; + data.push(getDataItem( + (this.miningStats.lastEstimatedHashrate - totalAcceleratedHashrate), + 'rgba(127, 127, 127, 0.3)', + $localize`not accelerating` + ` (${notAcceleratedByPercentage})`, + false, + ) as PieSeriesOption); + + return data; + } + + prepareChartOptions(pools: number[]) { + this.chartOptions = { + animation: false, + grid: { + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + tooltip: { + show: true, + trigger: 'item', + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: 'var(--tooltip-grey)', + }, + borderColor: '#000', + formatter: (item) => { + return item.name; + } + }, + series: [ + { + type: 'pie', + radius: '100%', + label: { + show: false + }, + labelLine: { + show: false + }, + animationDuration: 0, + data: this.getChartData(pools), + } + ] + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + this.chartInstance = ec; + } + + onToggleCpfp(): void { + this.toggleCpfp.emit(); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html index 377f8754a..be2bed134 100644 --- a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.html @@ -17,10 +17,10 @@
-
Total Vsize
+
Total vSize
-
-
{{ (stats.totalVsize / 1_000_000 * 100).toFixed(2) }}% of next block
+
+
{{ (stats.totalVsize / 1_000_000 * 100).toFixed(2) }}% of block
@@ -43,7 +43,7 @@
-
Total Vsize
+
Total vSize
diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss index fcc5564a8..733116776 100644 --- a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.scss @@ -1,5 +1,5 @@ .card-title { - color: #4a68b9; + color: var(--title-fg); font-size: 10px; margin-bottom: 4px; font-size: 1rem; @@ -34,7 +34,7 @@ @media (min-width: 376px) { margin: 0 auto 0px; } - &:first-child{ + &:last-child{ display: none; @media (min-width: 485px) { display: block; @@ -50,7 +50,7 @@ margin-bottom: 0; } .card-text span { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; top: 0px; } diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts index ed7061156..568e60d7e 100644 --- a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts @@ -2,7 +2,8 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { Acceleration } from '../../../interfaces/node-api.interface'; -import { ServicesApiServices } from '../../../services/services-api.service'; +import { StateService } from '../../../services/state.service'; +import { WebsocketService } from '../../../services/websocket.service'; @Component({ selector: 'app-pending-stats', @@ -15,11 +16,12 @@ export class PendingStatsComponent implements OnInit { public accelerationStats$: Observable; constructor( - private servicesApiService: ServicesApiServices, + private stateService: StateService, + private websocketService: WebsocketService, ) { } ngOnInit(): void { - this.accelerationStats$ = (this.accelerations$ || this.servicesApiService.getAccelerations$()).pipe( + this.accelerationStats$ = (this.accelerations$ || this.stateService.liveAccelerations$).pipe( switchMap(accelerations => { let totalAccelerations = 0; let totalFeeDelta = 0; diff --git a/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.html b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.html new file mode 100644 index 000000000..bf0080344 --- /dev/null +++ b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.html @@ -0,0 +1,5 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.scss b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.scss new file mode 100644 index 000000000..35f6e32d5 --- /dev/null +++ b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.scss @@ -0,0 +1,45 @@ +.sparkles { + position: absolute; + top: var(--block-size); + height: 50px; + right: 0; +} + +.sparkle { + position: absolute; + color: rgba(152, 88, 255, 0.75); + opacity: 0; + transform: scale(0.8) rotate(0deg); + animation: pop ease 2000ms forwards, sparkle ease 500ms infinite; +} + +.inner-sparkle { + display: block; +} + +@keyframes pop { + 0% { + transform: scale(0.8) rotate(0deg); + opacity: 0; + } + 20% { + transform: scale(1) rotate(72deg); + opacity: 1; + } + 100% { + transform: scale(0) rotate(360deg); + opacity: 0; + } +} + +@keyframes sparkle { + 0% { + color: rgba(152, 88, 255, 0.75); + } + 50% { + color: rgba(198, 162, 255, 0.75); + } + 100% { + color: rgba(152, 88, 255, 0.75); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.ts b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.ts new file mode 100644 index 000000000..2316c996d --- /dev/null +++ b/frontend/src/app/components/acceleration/sparkles/acceleration-sparkles.component.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; + +@Component({ + selector: 'app-acceleration-sparkles', + templateUrl: './acceleration-sparkles.component.html', + styleUrls: ['./acceleration-sparkles.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccelerationSparklesComponent implements OnChanges { + @Input() arrow: ElementRef; + @Input() run: boolean = false; + + @ViewChild('sparkleAnchor') + sparkleAnchor: ElementRef; + + constructor( + private cd: ChangeDetectorRef, + ) {} + + endTimeout: any; + lastSparkle: number = 0; + sparkleWidth: number = 0; + sparkles: any[] = []; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.run) { + if (this.endTimeout) { + clearTimeout(this.endTimeout); + this.endTimeout = null; + } + if (this.run) { + this.doSparkle(); + } else { + this.endTimeout = setTimeout(() => { + this.sparkles = []; + }, 2000); + } + } + } + + doSparkle(): void { + if (this.run) { + const now = performance.now(); + if (now - this.lastSparkle > 20) { + this.lastSparkle = now; + if (this.arrow?.nativeElement && this.sparkleAnchor?.nativeElement) { + const anchor = this.sparkleAnchor.nativeElement.getBoundingClientRect().right; + const right = this.arrow.nativeElement.getBoundingClientRect().right; + const dx = (anchor - right) + 30; + const numSparkles = Math.ceil(Math.random() * 3); + for (let i = 0; i < numSparkles; i++) { + this.sparkles.push({ + style: { + right: (dx + (Math.random() * 10)) + 'px', + top: (15 + (Math.random() * 30)) + 'px', + }, + rotation: { + transform: `rotate(${Math.random() * 360}deg)`, + } + }); + } + while (this.sparkles.length > 200) { + this.sparkles.shift(); + } + this.cd.markForCheck(); + } + } + requestAnimationFrame(() => { + this.doSparkle(); + }); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html index 35808cb14..c9dd072c8 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.html +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -1,14 +1,8 @@ - - -
-
-
- Balance History -
-
+ +
-
@@ -20,4 +14,8 @@

{{ error }}

+ +
+
+
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss index d23b95d8d..1b5e0320d 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.scss +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -45,28 +46,11 @@ display: flex; flex: 1; width: 100%; - padding-bottom: 20px; padding-right: 10px; - @media (max-width: 992px) { - padding-bottom: 25px; - } - @media (max-width: 829px) { - padding-bottom: 50px; - } - @media (max-width: 767px) { - padding-bottom: 25px; - } - @media (max-width: 629px) { - padding-bottom: 55px; - } - @media (max-width: 567px) { - padding-bottom: 55px; - } } .chart-widget { width: 100%; height: 100%; - max-height: 270px; } .disabled { diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index a73229495..6d40a8ebb 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,11 +1,25 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; -import { of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { ChainStats } from '../../interfaces/electrs.interface'; +import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { Router } from '@angular/router'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; +import { PriceService } from '../../services/price.service'; +import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; +import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; + +const periodSeconds = { + '1d': (60 * 60 * 24), + '3d': (60 * 60 * 24 * 3), + '1w': (60 * 60 * 24 * 7), + '1m': (60 * 60 * 24 * 30), + '6m': (60 * 60 * 24 * 180), + '1y': (60 * 60 * 24 * 365), +}; @Component({ selector: 'app-address-graph', @@ -16,20 +30,33 @@ import { Router } from '@angular/router'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddressGraphComponent implements OnChanges { +export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() address: string; @Input() isPubkey: boolean = false; @Input() stats: ChainStats; + @Input() addressSummary$: Observable | null; + @Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all'; + @Input() height: number = 200; @Input() right: number | string = 10; @Input() left: number | string = 70; + @Input() widget: boolean = false; data: any[] = []; + fiatData: any[] = []; hoverData: any[] = []; + conversions: any; + allowZoom: boolean = false; + initialRight = this.right; + initialLeft = this.left; + selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false }; + + subscription: Subscription; + redraw$: BehaviorSubject = new BehaviorSubject(false); chartOptions: EChartsOption = {}; chartInitOptions = { @@ -42,40 +69,114 @@ export class AddressGraphComponent implements OnChanges { constructor( @Inject(LOCALE_ID) public locale: string, + public stateService: StateService, private electrsApiService: ElectrsApiService, private router: Router, private amountShortenerPipe: AmountShortenerPipe, private cd: ChangeDetectorRef, + private relativeUrlPipe: RelativeUrlPipe, + private priceService: PriceService, + private fiatCurrencyPipe: FiatCurrencyPipe, + private fiatShortenerPipe: FiatShortenerPipe, + private zone: NgZone, ) {} ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - (this.isPubkey - ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') - : this.electrsApiService.getAddressSummary$(this.address)).pipe( - catchError(e => { - this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; - return of(null); - }), - ).subscribe(addressSummary => { - if (addressSummary) { - this.error = null; - this.prepareChartOptions(addressSummary); + if (!this.address || !this.stats) { + return; + } + if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { + if (this.subscription) { + this.subscription.unsubscribe(); } - this.isLoading = false; - this.cd.markForCheck(); - }); + this.subscription = combineLatest([ + this.redraw$, + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )), + this.stateService.conversions$ + ]).pipe( + switchMap(([redraw, addressSummary, conversions]) => { + this.conversions = conversions; + if (addressSummary) { + let extendedSummary = this.extendSummary(addressSummary); + return this.priceService.getPriceByBulk$(extendedSummary.map(d => d.time), 'USD').pipe( + tap((prices) => { + if (prices.length !== extendedSummary.length) { + extendedSummary = extendedSummary.map(item => ({ ...item, price: 0 })); + } else { + extendedSummary = extendedSummary.map((item, index) => { + let price = 0; + if (prices[index].price) { + price = prices[index].price['USD']; + } else if (this.conversions && this.conversions['USD']) { + price = this.conversions['USD']; + } + return { ...item, price: price } + }); + } + }), + map(() => [redraw, extendedSummary, conversions]) + ) + } else { + return of([redraw, addressSummary, conversions]); + } + }) + ).subscribe(([redraw, addressSummary, conversions]) => { + if (addressSummary) { + this.error = null; + this.allowZoom = addressSummary.length > 100 && !this.widget; + this.prepareChartOptions(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } else { + // re-trigger subscription + this.redraw$.next(true); + } } - prepareChartOptions(summary): void { - let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0); - this.data = summary.map(d => { - const balance = total; - total -= d.value; - return [d.time * 1000, balance, d]; + prepareChartOptions(summary: AddressTxSummary[]) { + if (!summary || !this.stats) { + return; + } + + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); + const processData = summary.map(d => { + const balance = total; + const fiatBalance = total * d.price / 100_000_000; + total -= d.value; + return { + time: d.time * 1000, + balance, + fiatBalance, + d + }; }).reverse(); + + this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]); + this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]); - const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0); + const now = Date.now(); + if (this.period !== 'all') { + const start = now - (periodSeconds[this.period] * 1000); + this.data = this.data.filter(d => d[0] >= start); + const startFiat = this.data[0]?.[0] ?? start; // Make sure USD data starts at the same time as BTC data + this.fiatData = this.fiatData.filter(d => d[0] >= startFiat); + } + this.data.push( + {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + ); + + const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); + const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); this.chartOptions = { color: [ @@ -83,14 +184,42 @@ export class AddressGraphComponent implements OnChanges { { offset: 0, color: '#FDD835' }, { offset: 1, color: '#FB8C00' }, ]), + new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#4CAF50' }, + { offset: 1, color: '#1B5E20' }, + ]), ], animation: false, grid: { top: 20, - bottom: 20, + bottom: this.allowZoom ? 65 : 20, right: this.right, left: this.left, }, + legend: !this.stateService.isAnyTestnet() ? { + data: [ + { + name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fiat', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + } + ], + selected: this.selected, + formatter: function (name) { + return name === 'Fiat' ? 'USD' : 'BTC'; + } + } : undefined, tooltip: { show: !this.isMobile(), trigger: 'axis', @@ -105,24 +234,64 @@ export class AddressGraphComponent implements OnChanges { align: 'left', }, borderColor: '#000', - formatter: function (data): string { - const header = data.length === 1 + formatter: function (data) { + const btcData = data.filter(d => d.seriesName !== 'Fiat'); + const fiatData = data.filter(d => d.seriesName === 'Fiat'); + data = btcData.length ? btcData : fiatData; + if ((!btcData.length || !btcData[0]?.data?.[2]?.txid) && !fiatData.length) { + return ''; + } + let tooltip = '
'; + + const hasTx = data[0].data[2].txid; + if (hasTx) { + const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; + tooltip += `${header}`; + } + const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); - const val = data.reduce((total, d) => total + d.data[2].value, 0); - const color = val === 0 ? '' : (val > 0 ? '#1a9436' : '#dc3545'); - const symbol = val > 0 ? '+' : ''; - return ` -
- ${header} -
- ${symbol} ${(val / 100_000_000).toFixed(8)} BTC
- ${(data[0].data[1] / 100_000_000).toFixed(8)} BTC -
- ${date} + + tooltip += `
+
`; + + const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal); + const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD'); + + const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0); + const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0); + const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)'); + const fiatColor = fiatVal === 0 ? '' : (fiatVal > 0 ? 'var(--green)' : 'var(--red)'); + const btcSymbol = btcVal > 0 ? '+' : ''; + const fiatSymbol = fiatVal > 0 ? '+' : ''; + + if (btcData.length && fiatData.length) { + tooltip += `
+ ${btcSymbol} ${formatBTC(btcVal, 4)} BTC + ${fiatSymbol} ${formatFiat(fiatVal)}
- `; +
+ ${formatBTC(btcData[0].data[1], 4)} BTC + ${formatFiat(fiatData[0].data[1])} +
`; + } else if (btcData.length) { + tooltip += `${btcSymbol} ${formatBTC(btcVal, 8)} BTC
+ ${formatBTC(data[0].data[1], 8)} BTC`; + } else { + if (this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]) { + tooltip += `
+ ${formatBTC(data[0].data[3], 4)} BTC + ${formatFiat(data[0].data[1])} +
`; + } else { + tooltip += `${hasTx ? `${fiatSymbol} ${formatFiat(fiatVal)}
` : ''} + ${formatFiat(data[0].data[1])}`; + } + } + + tooltip += `
${date}
`; + return tooltip; }.bind(this) }, xAxis: { @@ -139,13 +308,17 @@ export class AddressGraphComponent implements OnChanges { axisLabel: { color: 'rgb(110, 112, 121)', formatter: (val): string => { - if (maxValue > 1_000_000_000) { + let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); + if (valSpan > 100_000_000_000) { return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; - } else if (maxValue > 100_000_000) { + } + else if (valSpan > 1_000_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; + } else if (valSpan > 100_000_000) { return `${(val / 100_000_000).toFixed(1)} BTC`; - } else if (maxValue > 10_000_000) { + } else if (valSpan > 10_000_000) { return `${(val / 100_000_000).toFixed(2)} BTC`; - } else if (maxValue > 1_000_000) { + } else if (valSpan > 1_000_000) { return `${(val / 100_000_000).toFixed(3)} BTC`; } else { return `${this.amountShortenerPipe.transform(val, 0)} sats`; @@ -155,11 +328,26 @@ export class AddressGraphComponent implements OnChanges { splitLine: { show: false, }, + min: this.period === 'all' ? 0 : 'dataMin' + }, + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: function(val) { + return this.fiatShortenerPipe.transform(val, null, 'USD'); + }.bind(this) + }, + splitLine: { + show: false, + }, + min: this.period === 'all' ? 0 : 'dataMin' }, ], series: [ { - name: $localize`Balance:Balance`, + name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, + yAxisIndex: 0, showSymbol: false, symbol: 'circle', symbolSize: 8, @@ -171,14 +359,58 @@ export class AddressGraphComponent implements OnChanges { type: 'line', smooth: false, step: 'end' - } + }, !this.stateService.isAnyTestnet() ? + { + name: 'Fiat', + yAxisIndex: 1, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + data: this.fiatData, + areaStyle: { + opacity: 0.5, + }, + triggerLineEvent: true, + type: 'line', + smooth: false, + step: 'end' + } : undefined ], + dataZoom: this.allowZoom ? [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: this.left, + right: this.right, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + }, + }] : undefined }; } onChartClick(e) { if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) { - this.router.navigate(['/tx/', this.hoverData[0][2].txid]); + this.zone.run(() => { + const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`); + if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { + window.open(url); + } else { + this.router.navigate([url]); + } + }); } } @@ -186,13 +418,70 @@ export class AddressGraphComponent implements OnChanges { this.hoverData = (e?.dataByCoordSys?.[0]?.dataByAxis?.[0]?.seriesDataIndices || []).map(indices => this.data[indices.dataIndex]); } + onLegendSelectChanged(e) { + this.selected = e.selected; + this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight; + this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40; + + this.chartOptions = { + grid: { + right: this.right, + left: this.left, + }, + legend: { + selected: this.selected, + }, + dataZoom: this.allowZoom ? [{ + left: this.left, + right: this.right, + }, { + left: this.left, + right: this.right, + }] : undefined + }; + + if (this.chartInstance) { + this.chartInstance.setOption(this.chartOptions); + } + } + onChartInit(ec) { this.chartInstance = ec; this.chartInstance.on('showTip', this.onTooltip.bind(this)); this.chartInstance.on('click', 'series', this.onChartClick.bind(this)); + this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this)); + } + + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + } } isMobile() { return (window.innerWidth <= 767.98); } + + extendSummary(summary) { + let extendedSummary = summary.slice(); + + // Add a point at today's date to make the graph end at the current time + extendedSummary.unshift({ time: Date.now() / 1000, value: 0 }); + extendedSummary.reverse(); + + let oneHour = 60 * 60; + // Fill gaps longer than interval + for (let i = 0; i < extendedSummary.length - 1; i++) { + let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour); + if (hours > 1) { + for (let j = 1; j < hours; j++) { + let newTime = extendedSummary[i].time + oneHour * j; + extendedSummary.splice(i + j, 0, { time: newTime, value: 0 }); + } + i += hours - 1; + } + } + + return extendedSummary.reverse(); + } } diff --git a/frontend/src/app/components/address-group/address-group.component.scss b/frontend/src/app/components/address-group/address-group.component.scss index 0ac00b85b..1785e2a8e 100644 --- a/frontend/src/app/components/address-group/address-group.component.scss +++ b/frontend/src/app/components/address-group/address-group.component.scss @@ -1,6 +1,6 @@ .frame { position: relative; - background: #24273e; + background: var(--box-bg); padding: 0.5rem; height: calc(100% + 60px); } @@ -62,7 +62,7 @@ } } &:nth-child(even) { - background: #181b2d; + background: var(--stat-box-bg); } } diff --git a/frontend/src/app/components/address-labels/address-labels.component.html b/frontend/src/app/components/address-labels/address-labels.component.html index dfc6647f4..b055cf606 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.html +++ b/frontend/src/app/components/address-labels/address-labels.component.html @@ -4,7 +4,7 @@ {{ label }}
@@ -15,6 +15,6 @@ {{ label }} \ No newline at end of file diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index 2365c167f..ff3c27240 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -1,7 +1,7 @@ import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; import { Vin, Vout } from '../../interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; -import { parseMultisigScript } from '../../bitcoin.utils'; +import { AddressType, AddressTypeInfo } from '../../shared/address-utils'; @Component({ selector: 'app-address-labels', @@ -12,9 +12,11 @@ import { parseMultisigScript } from '../../bitcoin.utils'; export class AddressLabelsComponent implements OnChanges { network = ''; + @Input() address: AddressTypeInfo; @Input() vin: Vin; @Input() vout: Vout; @Input() channel: any; + @Input() class: string = ''; label?: string; @@ -27,6 +29,8 @@ export class AddressLabelsComponent implements OnChanges { ngOnChanges() { if (this.channel) { this.handleChannel(); + } else if (this.address) { + this.handleAddress(); } else if (this.vin) { this.handleVin(); } else if (this.vout) { @@ -41,74 +45,32 @@ export class AddressLabelsComponent implements OnChanges { this.label = `Channel ${type}: ${leftNodeName} <> ${rightNodeName}`; } - handleVin() { - if (this.vin.inner_witnessscript_asm) { - if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || this.vin.inner_witnessscript_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) { - if (this.vin.witness.length > 11) { - this.label = 'Liquid Peg Out'; - } else { - this.label = 'Emergency Liquid Peg Out'; - } - return; + handleAddress() { + if (this.address?.scripts.size) { + const script = this.address?.scripts.values().next().value; + if (script.template?.label) { + this.label = script.template.label; } - - const topElement = this.vin.witness[this.vin.witness.length - 2]; - if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(this.vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs - if (topElement === '01') { - // top element is '01' to get in the revocation path - this.label = 'Revoked Lightning Force Close'; - } else { - // top element is '', this is a delayed to_local output - this.label = 'Lightning Force Close'; - } - return; - } else if ( - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm) || - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm) - ) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs - // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs - if (topElement.length === 66) { - // top element is a public key - this.label = 'Revoked Lightning HTLC'; - } else if (topElement) { - // top element is a preimage - this.label = 'Lightning HTLC'; - } else { - // top element is '' to get in the expiry of the script - this.label = 'Expired Lightning HTLC'; - } - return; - } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors - if (topElement) { - // top element is a signature - this.label = 'Lightning Anchor'; - } else { - // top element is '', it has been swept after 16 blocks - this.label = 'Swept Lightning Anchor'; - } - return; - } - - this.detectMultisig(this.vin.inner_witnessscript_asm); } - - this.detectMultisig(this.vin.inner_redeemscript_asm); - - this.detectMultisig(this.vin.prevout.scriptpubkey_asm); } - detectMultisig(script: string) { - const ms = parseMultisigScript(script); - - if (ms) { - this.label = $localize`:@@address-label.multisig:Multisig ${ms.m}:multisigM: of ${ms.n}:multisigN:`; + handleVin() { + const address = new AddressTypeInfo(this.network || 'mainnet', this.vin.prevout?.scriptpubkey_address, this.vin.prevout?.scriptpubkey_type as AddressType, [this.vin]); + if (address?.scripts.size) { + const script = address?.scripts.values().next().value; + if (script.template?.label) { + this.label = script.template.label; + } } } handleVout() { - this.detectMultisig(this.vout.scriptpubkey_asm); + const address = new AddressTypeInfo(this.network || 'mainnet', this.vout.scriptpubkey_address, this.vout.scriptpubkey_type as AddressType, undefined, this.vout); + if (address?.scripts.size) { + const script = address?.scripts.values().next().value; + if (script.template?.label) { + this.label = script.template.label; + } + } } } diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html new file mode 100644 index 000000000..c1c999d6f --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +
 
+
TXIDAmount{{ currency }}Date
+ + + +
+ + + + +
+
+
+
+ + +
\ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss new file mode 100644 index 000000000..851da5996 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss @@ -0,0 +1,50 @@ +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-date { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} \ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts new file mode 100644 index 000000000..998d269ba --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs'; +import { PriceService } from '../../services/price.service'; + +@Component({ + selector: 'app-address-transactions-widget', + templateUrl: './address-transactions-widget.component.html', + styleUrls: ['./address-transactions-widget.component.scss'], +}) +export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, OnDestroy { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + currencySubscription: Subscription; + currency: string; + + transactions$: Observable; + + isLoading: boolean = true; + error: any; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private priceService: PriceService, + ) { } + + ngOnInit(): void { + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + this.startAddressSubscription(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.startAddressSubscription(); + } + + startAddressSubscription(): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + this.transactions$ = (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }) + )).pipe( + map(summary => { + return summary?.slice(0, 6); + }), + switchMap(txs => { + return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe( + map(price => { + return { + ...tx, + price, + }; + }) + )))); + }) + ); + } + + ngOnDestroy(): void { + this.currencySubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss index 21e7faab5..f03e13541 100644 --- a/frontend/src/app/components/address/address-preview.component.scss +++ b/frontend/src/app/components/address/address-preview.component.scss @@ -3,7 +3,7 @@ } .qr-wrapper { - background-color: #FFF; + background-color: #fff; padding: 10px; padding-bottom: 5px; display: inline-block; diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index 531b97464..31dff2fa5 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -3,7 +3,13 @@

Address

@@ -14,49 +20,75 @@
-
- - - - - - - - - - - - - - - - - - - - - -
Unconfidential - - - -
Total received
Total sent
Balance
-
-
-
-
- + @if (isMobile) { +
+ + + + + @if (!address.electrum) { + + + } + @if (network === 'liquid' || network === 'liquidtestnet') { + + } @else if (!address.electrum) { + + } + + +
-
+ } @else { +
+ + + + + + + + @if (!address.electrum) { + + + + + + } + + @if (network === 'liquid' || network === 'liquidtestnet') { + + } @else if (!address.electrum) { + + } @else { + + } + + + + +
+
+ }

+
+

Balance History

+
+
+ all + | + recent +
- +
@@ -66,8 +98,8 @@

  - {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction - {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions + {{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transaction + {{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transactions

@@ -109,25 +141,54 @@
-
- - - - - - - - - - - - -
-
-
-
- -
+ @if (isMobile) { +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ } @else { +
+ + + + + + + + + + + + + + + + + + +
+
+ }
@@ -140,7 +201,7 @@ Error loading address data.
- There many transactions on this address, more than your backend can handle. See more on setting up a stronger backend. + There are too many transactions on this address, more than your backend can handle. See more on setting up a stronger backend.

Consider viewing this address on the official Mempool website instead:
@@ -172,3 +233,58 @@
+ + + + + + + + + + + + Confirmed balance + + + + + Pending + + + + + Confirmed UTXOs + {{ chainStats.utxos }} + + + + Pending UTXOs + {{ mempoolStats.utxos > 0 ? '+' : ''}}{{ mempoolStats.utxos }} + + + + Total received + + + + + Type + + + + + + + + + + + Unconfidential + + + + + + + \ No newline at end of file diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index fe0729b94..be562337a 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -1,16 +1,20 @@ .qr-wrapper { - background-color: #FFF; + position: absolute; + top: 30px; + right: 0px; + border: solid 10px var(--active-bg); + border-radius: 5px; + background-color: #fff; padding: 10px; padding-bottom: 5px; - display: inline-block; + display: block; + z-index: 99; } -.qrcode-col { - margin: 20px auto 10px; - text-align: center; - @media (min-width: 992px){ - margin: 0px auto 0px; - } +.qrSpan { + position: relative; + cursor: pointer; + padding-left: 0.4rem; } .fiat { @@ -25,10 +29,14 @@ tr td { &:last-child { text-align: right; - @media (min-width: 576px) { + @media (min-width: 768px) { text-align: left; } } + + &.wrap-cell { + white-space: normal; + } } } @@ -78,10 +86,10 @@ h1 { top: 9px; position: relative; @media (min-width: 576px) { + max-width: calc(100% - 180px); top: 11px; } @media (min-width: 768px) { - max-width: calc(100% - 180px); top: 17px; } } @@ -96,16 +104,21 @@ h1 { .liquid-address { .address-table { table-layout: fixed; - - tr td:first-child { - width: 170px; - } - tr td:last-child { - width: 80%; - } - } - - .qrcode-col { - flex-grow: 0.5; } } + +.widget-toggler { + font-size: 12px; + position: absolute; + top: -20px; + right: 3px; + text-align: right; +} + +.toggler-option { + text-decoration: none; +} + +.inactive { + color: var(--transparent-fg); +} \ No newline at end of file diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 614be930c..105863a4e 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; -import { Address, ScriptHash, Transaction } from '../../interfaces/electrs.interface'; +import { Address, ChainStats, Transaction, Vin } from '../../interfaces/electrs.interface'; import { WebsocketService } from '../../services/websocket.service'; import { StateService } from '../../services/state.service'; import { AudioService } from '../../services/audio.service'; @@ -11,6 +11,83 @@ import { of, merge, Subscription, Observable } from 'rxjs'; import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; import { AddressInformation } from '../../interfaces/node-api.interface'; +import { AddressTypeInfo } from '../../shared/address-utils'; + +class AddressStats implements ChainStats { + address: string; + scriptpubkey?: string; + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + + constructor (stats: ChainStats, address: string, scriptpubkey?: string) { + Object.assign(this, stats); + this.address = address; + this.scriptpubkey = scriptpubkey; + } + + public addTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === this.address || (this.scriptpubkey === vin.prevout?.scriptpubkey)) { + this.spendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === this.address || (this.scriptpubkey === vout.scriptpubkey)) { + this.fundTxo(vout.value); + } + } + this.tx_count++; + } + + public removeTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === this.address || (this.scriptpubkey === vin.prevout?.scriptpubkey)) { + this.unspendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === this.address || (this.scriptpubkey === vout.scriptpubkey)) { + this.unfundTxo(vout.value); + } + } + this.tx_count--; + } + + private fundTxo(value: number): void { + this.funded_txo_sum += value; + this.funded_txo_count++; + } + + private unfundTxo(value: number): void { + this.funded_txo_sum -= value; + this.funded_txo_count--; + } + + private spendTxo(value: number): void { + this.spent_txo_sum += value; + this.spent_txo_count++; + } + + private unspendTxo(value: number): void { + this.spent_txo_sum -= value; + this.spent_txo_count--; + } + + get balance(): number { + return this.funded_txo_sum - this.spent_txo_sum; + } + + get totalReceived(): number { + return this.funded_txo_sum; + } + + get utxos(): number { + return this.funded_txo_count - this.spent_txo_count; + } +} @Component({ selector: 'app-address', @@ -20,6 +97,9 @@ import { AddressInformation } from '../../interfaces/node-api.interface'; export class AddressComponent implements OnInit, OnDestroy { network = ''; + isMobile: boolean; + showQR: boolean = false; + address: Address; addressString: string; isLoadingAddress = true; @@ -28,13 +108,21 @@ export class AddressComponent implements OnInit, OnDestroy { retryLoadMore = false; error: any; mainSubscription: Subscription; + mempoolTxSubscription: Subscription; + mempoolRemovedTxSubscription: Subscription; + blockTxSubscription: Subscription; addressLoadingStatus$: Observable; addressInfo: null | AddressInformation = null; + addressTypeInfo: null | AddressTypeInfo; fullyLoaded = false; - txCount = 0; - received = 0; - sent = 0; + chainStats: AddressStats; + mempoolStats: AddressStats; + + exampleChannel?: any; + + now = Date.now() / 1000; + balancePeriod: 'all' | '1m' = 'all'; private tempTransactions: Transaction[]; private timeTxIndexes: number[]; @@ -50,10 +138,12 @@ export class AddressComponent implements OnInit, OnDestroy { private seoService: SeoService, ) { } - ngOnInit() { + ngOnInit(): void { this.stateService.networkChanged$.subscribe((network) => this.network = network); this.websocketService.want(['blocks']); + this.onResize(); + this.addressLoadingStatus$ = this.route.paramMap .pipe( switchMap(() => this.stateService.loadingIndicators$), @@ -70,6 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.isLoadingTransactions = true; this.transactions = null; this.addressInfo = null; + this.exampleChannel = null; document.body.scrollTo(0, 0); this.addressString = params.get('id') || ''; if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(this.addressString)) { @@ -78,6 +169,8 @@ export class AddressComponent implements OnInit, OnDestroy { this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); + this.addressTypeInfo = new AddressTypeInfo(this.stateService.network || 'mainnet', this.addressString); + return merge( of(true), this.stateService.connectionState$ @@ -170,7 +263,24 @@ export class AddressComponent implements OnInit, OnDestroy { }); this.transactions = this.tempTransactions; + if (this.transactions.length === (this.mempoolStats.tx_count + this.chainStats.tx_count)) { + this.fullyLoaded = true; + } this.isLoadingTransactions = false; + + let addressVin: Vin[] = []; + for (const tx of this.transactions) { + addressVin = addressVin.concat(tx.vin.filter(v => v.prevout?.scriptpubkey_address === this.address.address)); + } + this.addressTypeInfo.processInputs(addressVin); + // hack to trigger change detection + this.addressTypeInfo = this.addressTypeInfo.clone(); + + if (!this.showBalancePeriod()) { + this.setBalancePeriod('all'); + } else { + this.setBalancePeriod('1m'); + } }, (error) => { console.log(error); @@ -179,28 +289,32 @@ export class AddressComponent implements OnInit, OnDestroy { this.isLoadingAddress = false; }); - this.stateService.mempoolTransactions$ + this.mempoolTxSubscription = this.stateService.mempoolTransactions$ .subscribe(tx => { this.addTransaction(tx); + this.mempoolStats.addTx(tx); }); - this.stateService.mempoolRemovedTransactions$ + this.mempoolRemovedTxSubscription = this.stateService.mempoolRemovedTransactions$ .subscribe(tx => { this.removeTransaction(tx); + this.mempoolStats.removeTx(tx); }); - this.stateService.blockTransactions$ + this.blockTxSubscription = this.stateService.blockTransactions$ .subscribe((transaction) => { const tx = this.transactions.find((t) => t.txid === transaction.txid); if (tx) { tx.status = transaction.status; this.transactions = this.transactions.slice(); + this.mempoolStats.removeTx(transaction); this.audioService.playSound('magic'); } else { if (this.addTransaction(transaction, false)) { this.audioService.playSound('magic'); } } + this.chainStats.addTx(transaction); }); } @@ -211,7 +325,6 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions.unshift(transaction); this.transactions = this.transactions.slice(); - this.txCount++; if (playSound) { if (transaction.vout.some((vout) => vout?.scriptpubkey_address === this.address.address)) { @@ -221,17 +334,6 @@ export class AddressComponent implements OnInit, OnDestroy { } } - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === this.address.address) { - this.sent += vin.prevout.value; - } - }); - transaction.vout.forEach((vout) => { - if (vout?.scriptpubkey_address === this.address.address) { - this.received += vout.value; - } - }); - return true; } @@ -243,23 +345,11 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions.splice(index, 1); this.transactions = this.transactions.slice(); - this.txCount--; - - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === this.address.address) { - this.sent -= vin.prevout.value; - } - }); - transaction.vout.forEach((vout) => { - if (vout?.scriptpubkey_address === this.address.address) { - this.received -= vout.value; - } - }); return true; } - loadMore() { + loadMore(): void { if (this.isLoadingTransactions || this.fullyLoaded) { return; } @@ -287,14 +377,33 @@ export class AddressComponent implements OnInit, OnDestroy { }); } - updateChainStats() { - this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum; - this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum; - this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; + updateChainStats(): void { + this.chainStats = new AddressStats(this.address.chain_stats, this.address.address); + this.mempoolStats = new AddressStats(this.address.mempool_stats, this.address.address); } - ngOnDestroy() { + setBalancePeriod(period: 'all' | '1m'): boolean { + this.balancePeriod = period; + return false; + } + + showBalancePeriod(): boolean { + return this.transactions?.length && ( + !this.transactions[0].status?.confirmed + || this.transactions[0].status.block_time > (this.now - (60 * 60 * 24 * 30)) + ); + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth < 768; + } + + ngOnDestroy(): void { this.mainSubscription.unsubscribe(); + this.mempoolTxSubscription.unsubscribe(); + this.mempoolRemovedTxSubscription.unsubscribe(); + this.blockTxSubscription.unsubscribe(); this.websocketService.stopTrackingAddress(); } } diff --git a/frontend/src/app/components/amount/amount.component.html b/frontend/src/app/components/amount/amount.component.html index 9ca0ba939..b513c89d2 100644 --- a/frontend/src/app/components/amount/amount.component.html +++ b/frontend/src/app/components/amount/amount.component.html @@ -1,4 +1,4 @@ - + {{ addPlus && satoshis >= 0 ? '+' : '' }}{{ ( @@ -20,10 +20,29 @@ Confidential - ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} - L- - tL- - t - sBTC + + @if ((viewAmountMode$ | async) === 'btc' || (viewAmountMode$ | async) === 'fiat' || ignoreViewMode === true) { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} + + BTC + + } @else { + @if (digitsInfo === '1.8-8') { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }} + } @else { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }} + } + + sats + + } + + + L- + tL- + t + t + s + diff --git a/frontend/src/app/components/amount/amount.component.scss b/frontend/src/app/components/amount/amount.component.scss index b26429dcc..87f2fe6cd 100644 --- a/frontend/src/app/components/amount/amount.component.scss +++ b/frontend/src/app/components/amount/amount.component.scss @@ -1,3 +1,3 @@ .green-color { - color: #3bcc49; + color: var(--green); } \ No newline at end of file diff --git a/frontend/src/app/components/amount/amount.component.ts b/frontend/src/app/components/amount/amount.component.ts index 9d0337574..93715f3c0 100644 --- a/frontend/src/app/components/amount/amount.component.ts +++ b/frontend/src/app/components/amount/amount.component.ts @@ -12,7 +12,7 @@ import { Price } from '../../services/price.service'; export class AmountComponent implements OnInit, OnDestroy { conversions$: Observable; currency: string; - viewFiat$: Observable; + viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>; network = ''; stateSubscription: Subscription; @@ -24,6 +24,7 @@ export class AmountComponent implements OnInit, OnDestroy { @Input() addPlus = false; @Input() blockConversion: Price; @Input() forceBtc: boolean = false; + @Input() ignoreViewMode: boolean = false; @Input() forceBlockConversion: boolean = false; // true = displays fiat price as 0 if blockConversion is undefined instead of falling back to conversions constructor( @@ -37,7 +38,7 @@ export class AmountComponent implements OnInit, OnDestroy { } ngOnInit() { - this.viewFiat$ = this.stateService.viewFiat$.asObservable(); + this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable(); this.conversions$ = this.stateService.conversions$.asObservable(); this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); } diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index ace0122f0..453276966 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -4,6 +4,8 @@ import { Router, NavigationEnd } from '@angular/router'; import { StateService } from '../../services/state.service'; import { OpenGraphService } from '../../services/opengraph.service'; import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; +import { ThemeService } from '../../services/theme.service'; +import { SeoService } from '../../services/seo.service'; @Component({ selector: 'app-root', @@ -12,12 +14,12 @@ import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; providers: [NgbTooltipConfig] }) export class AppComponent implements OnInit { - link: HTMLElement = document.getElementById('canonical'); - constructor( public router: Router, private stateService: StateService, private openGraphService: OpenGraphService, + private seoService: SeoService, + private themeService: ThemeService, private location: Location, tooltipConfig: NgbTooltipConfig, @Inject(LOCALE_ID) private locale: string, @@ -52,11 +54,7 @@ export class AppComponent implements OnInit { ngOnInit() { this.router.events.subscribe((val) => { if (val instanceof NavigationEnd) { - let domain = 'mempool.space'; - if (this.stateService.env.BASE_MODULE === 'liquid') { - domain = 'liquid.network'; - } - this.link.setAttribute('href', 'https://' + domain + this.location.path()); + this.seoService.updateCanonical(this.location.path()); } }); } diff --git a/frontend/src/app/components/asset/asset.component.scss b/frontend/src/app/components/asset/asset.component.scss index 45e68042d..56b1d6258 100644 --- a/frontend/src/app/components/asset/asset.component.scss +++ b/frontend/src/app/components/asset/asset.component.scss @@ -1,5 +1,5 @@ .qr-wrapper { - background-color: #FFF; + background-color: #fff; padding: 10px; padding-bottom: 5px; display: inline-block; diff --git a/frontend/src/app/components/assets/asset-group/asset-group.component.scss b/frontend/src/app/components/assets/asset-group/asset-group.component.scss index c0b31f273..d434c16ef 100644 --- a/frontend/src/app/components/assets/asset-group/asset-group.component.scss +++ b/frontend/src/app/components/assets/asset-group/asset-group.component.scss @@ -19,7 +19,7 @@ } .card { - background-color: #1d1f31; + background-color: var(--bg); width: 200px; height: 200px; align-items: center; diff --git a/frontend/src/app/components/assets/assets-featured/assets-featured.component.scss b/frontend/src/app/components/assets/assets-featured/assets-featured.component.scss index 32c560af4..c80c3689d 100644 --- a/frontend/src/app/components/assets/assets-featured/assets-featured.component.scss +++ b/frontend/src/app/components/assets/assets-featured/assets-featured.component.scss @@ -7,7 +7,7 @@ } .card { - background-color: #1d1f31; + background-color: var(--bg); width: 200px; height: 200px; align-items: center; diff --git a/frontend/src/app/components/assets/assets.component.html b/frontend/src/app/components/assets/assets.component.html index c279af2ab..30c6b7255 100644 --- a/frontend/src/app/components/assets/assets.component.html +++ b/frontend/src/app/components/assets/assets.component.html @@ -18,7 +18,7 @@
- +

diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.html b/frontend/src/app/components/balance-widget/balance-widget.component.html new file mode 100644 index 000000000..4923a2c06 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.html @@ -0,0 +1,59 @@ +
+
+
+
+
BTC Holdings
+
+ {{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (7d)
+
+ {{ delta7d > 0 ? '+' : ''}}{{ ((delta7d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (30d)
+
+ {{ delta30d > 0 ? '+' : ''}}{{ ((delta30d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
+
+ + +
+
+
BTC Holdings
+
+
+
+
+
+
+
Change (7d)
+
+
+
+
+
+
+
Change (30d)
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.scss b/frontend/src/app/components/balance-widget/balance-widget.component.scss new file mode 100644 index 000000000..a2f803c79 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.scss @@ -0,0 +1,160 @@ +.balance-container { + display: flex; + flex-direction: row; + justify-content: space-around; + height: 76px; + .shared-block { + color: var(--transparent-fg); + font-size: 12px; + } + .item { + padding: 0 5px; + width: 100%; + max-width: 150px; + &:last-child { + display: none; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + } + .card-text { + font-size: 22px; + margin-top: -9px; + position: relative; + } +} + + +.balance-skeleton { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + min-width: 120px; + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:last-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + } + .card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + margin: 14px auto 0; + max-width: 80px; + } + &:last-child { + margin: 10px auto 0; + max-width: 120px; + } + } + } +} + +.card { + background-color: var(--bg); + height: 126px; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 24px 20px; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 24px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.symbol { + font-size: 13px; + white-space: nowrap; +} \ No newline at end of file diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.ts b/frontend/src/app/components/balance-widget/balance-widget.component.ts new file mode 100644 index 000000000..8e1d3f442 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, catchError, of } from 'rxjs'; + +@Component({ + selector: 'app-balance-widget', + templateUrl: './balance-widget.component.html', + styleUrls: ['./balance-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BalanceWidgetComponent implements OnInit, OnChanges { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + isLoading: boolean = true; + error: any; + + delta7d: number = 0; + delta30d: number = 0; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + + } + + ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )).subscribe(addressSummary => { + if (addressSummary) { + this.error = null; + this.calculateStats(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + calculateStats(summary: AddressTxSummary[]): void { + let weekTotal = 0; + let monthTotal = 0; + + const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000; + const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000; + for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) { + monthTotal += summary[i].value; + if (summary[i].time >= weekAgo) { + weekTotal += summary[i].value; + } + } + this.delta7d = weekTotal; + this.delta30d = monthTotal; + } +} diff --git a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html new file mode 100644 index 000000000..790f046f7 --- /dev/null +++ b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html @@ -0,0 +1,99 @@ +
+ + @if (!minimal) { + + Payment successful. You can close this page. + + + + A transaction has been detected in the mempool fully paying for this invoice. Waiting for on-chain confirmation. + + } + +
+ +
+ +
+
+ + + +
+
+ +
+ + + +
+ + + +
+ +
+ +
+ +
+
+ @if (!minimal) { +

{{ invoice.btcDue | number: '1.0-8' }} BTC

+ } + +
+ + + +
+ + + +
+ +
+ +
+ +
+
+ + @if (!minimal) { +

{{ invoice.btcDue * 100_000_000 | number: '1.0-0' }} sats

+ } + +
+ + + +
+ + + +
+
+
+ +
+ +
+
+ @if (!minimal) { +

{{ invoice.btcDue | number: '1.0-8' }} BTC

+ } + +
+ + @if (!minimal) { +

Waiting for transaction...

+
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.scss b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.scss new file mode 100644 index 000000000..b88a2ef74 --- /dev/null +++ b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.scss @@ -0,0 +1,150 @@ +.form-panel { + background-color: #292b45; + padding: 20px; +} + + +.sponsor-page { + text-align: center; +} + +.qr-wrapper { + background-color: #FFF; + padding: 10px; + display: inline-block; + padding-bottom: 5px; + margin: 20px auto 0px; +} + +.info-group { + max-width: 400px; +} + +.card { + width: 240px; + height: 220px; + background-color: var(--bg); + border: 2px solid var(--bg); + cursor: pointer; + position: relative; + transition: 100ms all; + margin: 30px 30px 20px 30px; + @media(min-width: 476px) { + margin: 30px 100px 20px 100px; + } + @media(min-width: 851px) { + margin: 60px 20px 40px 20px; + } + + .card-title { + font-weight: bold; + span { + font-weight: 100; + } + } + + &.bigger { + height: 220px; + width: 240px; + margin-top: 40px; + } + + &:hover { + background-color: #5058926b; + border: 2px solid #505892; + transform: scale(1.1) translateY(-10px); + margin-top: 70px; + + .card-header { + background-color: #505892; + } + } +} + +.donation-form { + max-width: 280px; + margin: auto; + button { + width: 100%; + } +} + +.card-header { + background-color: #171929; +} + +.flex-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; +} + +.middle-card { + width: 280px; + height: 260px; + margin-top: 40px; + &:hover { + margin-top: 50px; + } +} + +.shiny-border { + background-color: #5058926b; + border: 2px solid #505892; + transform: scale(1.1) translateY(-10px); + margin-top: 70px; + box-shadow: 0px 0px 100px #9858ff52; + .card-header { + background-color: #505892; + } + + &.middle-card { + margin-top: 50px; + } +} + +.input-group { + margin: 20px auto; +} + +.donation-confirmed { + h2 { + margin-top: 50px; + span { + display: block; + &:last-child { + color: #9858ff; + font-weight: bold; + font-size: 2rem; + } + } + } + + .order-details { + margin-top: 50px; + span { + color: #d81b60; + margin-left: 10px; + } + } +} + +.card-body { + align-items: center; + display: flex; + justify-content: center; + flex-direction: column; + height: 100%; +} + +.wrapper { + text-align: center; + width: 100%; +} + +.input-dark { + background-color: var(--bg); + border-color: var(--active-bg); + color: white; +} diff --git a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts new file mode 100644 index 000000000..067061678 --- /dev/null +++ b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts @@ -0,0 +1,93 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription, of, timer } from 'rxjs'; +import { filter, repeat, retry, switchMap, take, tap } from 'rxjs/operators'; +import { ServicesApiServices } from '../../services/services-api.service'; + +@Component({ + selector: 'app-bitcoin-invoice', + templateUrl: './bitcoin-invoice.component.html', + styleUrls: ['./bitcoin-invoice.component.scss'] +}) +export class BitcoinInvoiceComponent implements OnInit, OnChanges, OnDestroy { + @Input() invoice; + @Input() redirect = true; + @Input() minimal = false; + @Output() completed = new EventEmitter(); + + paymentForm: FormGroup; + requestSubscription: Subscription | undefined; + paymentStatusSubscription: Subscription | undefined; + paymentStatus = 1; // 1 - Waiting for invoice | 2 - Pending payment | 3 - Payment completed + paramMapSubscription: Subscription | undefined; + invoiceSubscription: Subscription | undefined; + invoiceTimeout; // Wait for angular to load all the things before making a request + + constructor( + private formBuilder: FormBuilder, + private apiService: ServicesApiServices, + private sanitizer: DomSanitizer, + private activatedRoute: ActivatedRoute + ) { } + + ngOnDestroy() { + if (this.requestSubscription) { + this.requestSubscription.unsubscribe(); + } + if (this.paramMapSubscription) { + this.paramMapSubscription.unsubscribe(); + } + if (this.invoiceSubscription) { + this.invoiceSubscription.unsubscribe(); + } + if (this.paymentStatusSubscription) { + this.paymentStatusSubscription.unsubscribe(); + } + } + + ngOnInit(): void { + this.paymentForm = this.formBuilder.group({ + 'method': 'lightning' + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.invoice) { + this.watchInvoice(); + } + } + + watchInvoice(): void { + if (this.paymentStatusSubscription) { + this.paymentStatusSubscription.unsubscribe(); + } + if (!this.invoice) { + this.paymentStatus = 1; + return; + } + if (this.invoice.btcDue > 0) { + this.paymentStatus = 2; + } else { + this.paymentStatus = 4; + } + this.paymentStatusSubscription = this.apiService.getPaymentStatus$(this.invoice.btcpayInvoiceId).pipe( + retry({ delay: () => timer(2000)}), + repeat({delay: 2000}), + filter((response) => response.status !== 204 && response.status !== 404), + take(1), + ).subscribe(() => { + this.paymentStatus = 3; + this.completed.emit(); + }); + } + + get availableMethods(): string[] { + return Object.keys(this.invoice?.addresses || {}).filter(k => k === 'BTC_LightningLike'); + } + + bypassSecurityTrustUrl(text: string): SafeUrl { + return this.sanitizer.bypassSecurityTrustUrl(text); + } +} diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html index 9c27ecb83..e9de40559 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html @@ -10,7 +10,7 @@
-
+
@@ -63,7 +63,7 @@
+ (chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss index 21dd458b5..e2883e8b3 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -95,12 +96,12 @@ } .card-title { font-size: 1rem; - color: #4a68b9; + color: var(--title-fg); } .card-text { font-size: 18px; span { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; } } @@ -138,8 +139,3 @@ max-width: 80px; margin: 15px auto 3px; } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts index 0abc55aa7..c533626e7 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts @@ -23,7 +23,7 @@ import { ActivatedRoute, Router } from '@angular/router'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -233,7 +233,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', textStyle: { - color: '#b1b1b1', + color: 'var(--tooltip-grey)', align: 'left', }, borderColor: '#000', @@ -309,7 +309,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -376,7 +376,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { const now = new Date(); // @ts-ignore this.chartOptions.grid.bottom = 40; - this.chartOptions.backgroundColor = '#11131f'; + this.chartOptions.backgroundColor = 'var(--active-bg)'; this.chartInstance.setOption(this.chartOptions); download(this.chartInstance.getDataURL({ pixelRatio: 2, diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html index 4fcbc3595..0328e7eb1 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html @@ -10,7 +10,7 @@
-
+
@@ -37,7 +37,7 @@
+ (chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss index b73d55685..b09599425 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -59,8 +60,3 @@ height: 100%; max-height: 270px; } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts index 895a6f33f..33e3eb19e 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts @@ -23,7 +23,7 @@ import { StateService } from '../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -151,7 +151,7 @@ export class BlockFeesGraphComponent implements OnInit { borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', textStyle: { - color: '#b1b1b1', + color: 'var(--tooltip-grey)', align: 'left', }, borderColor: '#000', @@ -214,7 +214,7 @@ export class BlockFeesGraphComponent implements OnInit { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -305,7 +305,7 @@ export class BlockFeesGraphComponent implements OnInit { const now = new Date(); // @ts-ignore this.chartOptions.grid.bottom = 40; - this.chartOptions.backgroundColor = '#11131f'; + this.chartOptions.backgroundColor = 'var(--active-bg)'; this.chartInstance.setOption(this.chartOptions); download(this.chartInstance.getDataURL({ pixelRatio: 2, diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html new file mode 100644 index 000000000..341b16640 --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html @@ -0,0 +1,55 @@ + + +
+
+
+ Block Fees Vs Subsidy + +
+ + +
+ + + + + + + + + + +
+ +
+ +
+
+
+
+
+ +
diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss new file mode 100644 index 000000000..f0a89b37b --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss @@ -0,0 +1,61 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } + @media (min-width: 992px) { + height: 40px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 225px); + min-height: 400px; + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart { + display: flex; + flex: 1; + width: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts new file mode 100644 index 000000000..42d05510f --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts @@ -0,0 +1,573 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core'; +import { EChartsOption } from '../../graphs/echarts'; +import { Observable } from 'rxjs'; +import { catchError, map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { SeoService } from '../../services/seo.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download, formatterXAxis } from '../../shared/graphs.utils'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; +import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; +import { StateService } from '../../services/state.service'; +import { MiningService } from '../../services/mining.service'; +import { StorageService } from '../../services/storage.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; + +@Component({ + selector: 'app-block-fees-subsidy-graph', + templateUrl: './block-fees-subsidy-graph.component.html', + styleUrls: ['./block-fees-subsidy-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 99; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockFeesSubsidyGraphComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + data: any; + subsidies: { [key: number]: number } = {}; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + displayMode: 'normal' | 'fiat' | 'percentage' = 'normal'; + updateZoom = false; + zoomSpan = 100; + zoomTimeSpan = ''; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: UntypedFormBuilder, + public stateService: StateService, + private storageService: StorageService, + private miningService: MiningService, + private route: ActivatedRoute, + private router: Router, + private zone: NgZone, + private fiatShortenerPipe: FiatShortenerPipe, + private fiatCurrencyPipe: FiatCurrencyPipe, + private cd: ChangeDetectorRef, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + + this.subsidies = this.initSubsidies(); + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@41545303ec98792b738d6237adbd1f3b54a22196:Block Fees Vs Subsidy`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees-subsidy:See the mining fees earned per Bitcoin block compared to the Bitcoin block subsidy, visualized in BTC and USD over time.`); + + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.route + .fragment + .subscribe((fragment) => { + if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } + }); + + this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + switchMap((timespan) => { + this.isLoading = true; + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + this.zoomTimeSpan = timespan; + return this.apiService.getHistoricalBlockFees$(timespan) + .pipe( + tap((response) => { + this.data = { + timestamp: response.body.map(val => val.timestamp * 1000), + blockHeight: response.body.map(val => val.avgHeight), + blockFees: response.body.map(val => val.avgFees / 100_000_000), + blockFeesFiat: response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD']), + blockFeesPercent: response.body.map(val => val.avgFees / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100), + blockSubsidy: response.body.map(val => this.subsidyAt(val.avgHeight) / 100_000_000), + blockSubsidyFiat: response.body.filter(val => val['USD'] > 0).map(val => this.subsidyAt(val.avgHeight) / 100_000_000 * val['USD']), + blockSubsidyPercent: response.body.map(val => this.subsidyAt(val.avgHeight) / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100), + }; + + this.prepareChartOptions(); + this.isLoading = false; + }), + map((response) => { + return { + blockCount: parseInt(response.headers.get('x-total-count'), 10), + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions() { + let title: object; + if (this.data.blockFees.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + color: [ + '#ff9f00', + '#0aab2f', + ], + animation: false, + grid: { + top: 80, + bottom: 80, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: 'var(--tooltip-grey)', + align: 'left', + }, + borderColor: 'var(--active-bg)', + formatter: function (data) { + if (data.length <= 0) { + return ''; + } + let tooltip = `${formatterXAxis(this.locale, this.zoomTimeSpan, parseInt(this.data.timestamp[data[0].dataIndex], 10))}
`; + for (let i = data.length - 1; i >= 0; i--) { + const tick = data[i]; + tooltip += `${tick.marker} ${tick.seriesName.split(' ')[0]}: `; + if (this.displayMode === 'normal') tooltip += `${formatNumber(tick.data, this.locale, '1.0-3')} BTC
`; + else if (this.displayMode === 'fiat') tooltip += `${this.fiatCurrencyPipe.transform(tick.data, null, 'USD') }
`; + else tooltip += `${formatNumber(tick.data, this.locale, '1.0-2')}%
`; + } + if (this.displayMode === 'normal') tooltip += `
${formatNumber(data.reduce((acc, val) => acc + val.data, 0), this.locale, '1.0-3')} BTC
`; + else if (this.displayMode === 'fiat') tooltip += `
${this.fiatCurrencyPipe.transform(data.reduce((acc, val) => acc + val.data, 0), null, 'USD')}
`; + if (['24h', '3d'].includes(this.zoomTimeSpan)) { + tooltip += `` + $localize`At block ${'' + data[0].axisValue}` + ``; + } else { + tooltip += `` + $localize`Around block ${'' + data[0].axisValue}` + ``; + } + return tooltip; + }.bind(this) + }, + xAxis: this.data.blockFees.length === 0 ? undefined : [ + { + type: 'category', + data: this.data.blockHeight, + show: false, + axisLabel: { + hideOverlap: true, + } + }, + { + type: 'category', + data: this.data.timestamp, + show: true, + position: 'bottom', + axisLabel: { + color: 'var(--grey)', + formatter: (val) => { + return formatterXAxis(this.locale, this.timespan, parseInt(val, 10)); + } + }, + axisTick: { + show: false, + }, + axisLine: { + show: false, + }, + splitLine: { + show: false, + }, + } + ], + legend: this.data.blockFees.length === 0 ? undefined : { + data: [ + { + name: 'Subsidy', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fees', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Subsidy (USD)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fees (USD)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Subsidy (%)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fees (%)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: { + 'Subsidy (USD)': this.displayMode === 'fiat', + 'Fees (USD)': this.displayMode === 'fiat', + 'Subsidy': this.displayMode === 'normal', + 'Fees': this.displayMode === 'normal', + 'Subsidy (%)': this.displayMode === 'percentage', + 'Fees (%)': this.displayMode === 'percentage', + }, + }, + yAxis: this.data.blockFees.length === 0 ? undefined : [ + { + type: 'value', + axisLabel: { + color: 'var(--grey)', + formatter: (val) => { + return `${val}${this.displayMode === 'percentage' ? '%' : ' BTC'}`; + } + }, + min: 0, + max: (value) => { + if (this.displayMode === 'percentage') { + return 100; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + }, + { + type: 'value', + position: 'right', + axisLabel: { + color: 'var(--grey)', + formatter: function(val) { + return this.fiatShortenerPipe.transform(val, null, 'USD'); + }.bind(this) + }, + splitLine: { + show: false, + }, + }, + ], + series: this.data.blockFees.length === 0 ? undefined : [ + { + name: 'Subsidy', + yAxisIndex: 0, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockSubsidy, + }, + { + name: 'Fees', + yAxisIndex: 0, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockFees, + }, + { + name: 'Subsidy (USD)', + yAxisIndex: 1, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockSubsidyFiat, + }, + { + name: 'Fees (USD)', + yAxisIndex: 1, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockFeesFiat, + }, + { + name: 'Subsidy (%)', + yAxisIndex: 0, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockSubsidyPercent, + }, + { + name: 'Fees (%)', + yAxisIndex: 0, + type: 'bar', + barWidth: '90%', + stack: 'total', + data: this.data.blockFeesPercent, + }, + ], + dataZoom: this.data.blockFees.length === 0 ? undefined : [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 1, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + }, + }], + }; + } + + onChartInit(ec) { + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (params) => { + if (this.isLoading) { + return; + } + + let mode: 'normal' | 'fiat' | 'percentage'; + if (params.name.includes('USD')) { + mode = 'fiat'; + } else if (params.name.includes('%')) { + mode = 'percentage'; + } else { + mode = 'normal'; + } + + if (this.displayMode === mode) return; + + const isActivation = params.selected[params.name]; + + if (isActivation) { + this.displayMode = mode; + this.chartInstance.dispatchAction({ type: this.displayMode === 'normal' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy' }); + this.chartInstance.dispatchAction({ type: this.displayMode === 'normal' ? 'legendSelect' : 'legendUnSelect', name: 'Fees' }); + this.chartInstance.dispatchAction({ type: this.displayMode === 'fiat' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy (USD)' }); + this.chartInstance.dispatchAction({ type: this.displayMode === 'fiat' ? 'legendSelect' : 'legendUnSelect', name: 'Fees (USD)' }); + this.chartInstance.dispatchAction({ type: this.displayMode === 'percentage' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy (%)' }); + this.chartInstance.dispatchAction({ type: this.displayMode === 'percentage' ? 'legendSelect' : 'legendUnSelect', name: 'Fees (%)' }); + } + }); + + this.chartInstance.on('datazoom', (params) => { + if (params.silent || this.isLoading || ['24h', '3d'].includes(this.timespan)) { + return; + } + this.updateZoom = true; + }); + + this.chartInstance.on('click', (e) => { + this.zone.run(() => { + if (['24h', '3d'].includes(this.zoomTimeSpan)) { + const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.name}`); + if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { + window.open(url); + } else { + this.router.navigate([url]); + } + } + }); + }); + } + + @HostListener('document:pointerup', ['$event']) + onPointerUp(event: PointerEvent) { + if (this.updateZoom) { + this.onZoom(); + this.updateZoom = false; + } + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + initSubsidies(): { [key: number]: number } { + let blockReward = 50 * 100_000_000; + const subsidies = {}; + for (let i = 0; i <= 33; i++) { + subsidies[i] = blockReward; + blockReward = Math.floor(blockReward / 2); + } + return subsidies; + } + + subsidyAt(height: number): number { + return this.subsidies[Math.floor(Math.min(height / 210000, 33))]; + } + + onZoom() { + const option = this.chartInstance.getOption(); + const timestamps = option.xAxis[1].data; + const startTimestamp = timestamps[option.dataZoom[0].startValue]; + const endTimestamp = timestamps[option.dataZoom[0].endValue]; + + this.isLoading = true; + this.cd.detectChanges(); + + const subscription = this.apiService.getBlockFeesFromTimespan$(Math.floor(startTimestamp / 1000), Math.floor(endTimestamp / 1000)) + .pipe( + tap((response) => { + const startIndex = option.dataZoom[0].startValue; + const endIndex = option.dataZoom[0].endValue; + + // Update series with more granular data + const lengthBefore = this.data.timestamp.length; + this.data.timestamp.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.timestamp * 1000)); + this.data.blockHeight.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgHeight)); + this.data.blockFees.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgFees / 100_000_000)); + this.data.blockFeesFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD'])); + this.data.blockFeesPercent.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgFees / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100)); + this.data.blockSubsidy.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidyAt(val.avgHeight) / 100_000_000)); + this.data.blockSubsidyFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => this.subsidyAt(val.avgHeight) / 100_000_000 * val['USD'])); + this.data.blockSubsidyPercent.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidyAt(val.avgHeight) / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100)); + option.series[0].data = this.data.blockSubsidy; + option.series[1].data = this.data.blockFees; + option.series[2].data = this.data.blockSubsidyFiat; + option.series[3].data = this.data.blockFeesFiat; + option.series[4].data = this.data.blockSubsidyPercent; + option.series[5].data = this.data.blockFeesPercent; + option.xAxis[0].data = this.data.blockHeight; + option.xAxis[1].data = this.data.timestamp; + this.chartInstance.setOption(option, true); + const lengthAfter = this.data.timestamp.length; + + // Update the zoom to keep the same range after the update + this.chartInstance.dispatchAction({ + type: 'dataZoom', + startValue: startIndex, + endValue: endIndex + lengthAfter - lengthBefore, + silent: true, + }); + + // Update the chart + const newOption = this.chartInstance.getOption(); + this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start; + this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000)); + this.isLoading = false; + }), + catchError(() => { + const newOption = this.chartInstance.getOption(); + this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start; + this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000)); + this.isLoading = false; + this.cd.detectChanges(); + return []; + }) + ).subscribe(() => { + subscription.unsubscribe(); + this.cd.detectChanges(); + }); + } + + getTimeRangeFromTimespan(from: number, to: number): string { + const timespan = to - from; + switch (true) { + case timespan >= 3600 * 24 * 365 * 4: return 'all'; + case timespan >= 3600 * 24 * 365 * 3: return '4y'; + case timespan >= 3600 * 24 * 365 * 2: return '3y'; + case timespan >= 3600 * 24 * 365: return '2y'; + case timespan >= 3600 * 24 * 30 * 6: return '1y'; + case timespan >= 3600 * 24 * 30 * 3: return '6m'; + case timespan >= 3600 * 24 * 30: return '3m'; + case timespan >= 3600 * 24 * 7: return '1m'; + case timespan >= 3600 * 24 * 3: return '1w'; + case timespan >= 3600 * 24: return '3d'; + default: return '24h'; + } + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = 'var(--active-bg)'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `block-fees-subsidy-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } + +} diff --git a/frontend/src/app/components/block-filters/block-filters.component.html b/frontend/src/app/components/block-filters/block-filters.component.html index 2a22681d6..be692cfa9 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.html +++ b/frontend/src/app/components/block-filters/block-filters.component.html @@ -1,6 +1,5 @@
- - beta +
diff --git a/frontend/src/app/components/block-filters/block-filters.component.scss b/frontend/src/app/components/block-filters/block-filters.component.scss index b1c4bce17..4312dd7b2 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.scss +++ b/frontend/src/app/components/block-filters/block-filters.component.scss @@ -24,7 +24,6 @@ display: flex; flex-direction: row; align-items: center; - float: right; &:hover, &:active { text-decoration: none; @@ -32,16 +31,18 @@ } .menu-toggle { - width: 2em; - height: 2em; + width: 3em; + min-width: 3em; + height: 1.8em; padding: 0px 1px; opacity: 0; cursor: pointer; color: white; background: none; - border: solid 2px white; + border: none; border-radius: 0.35em; pointer-events: all; + align-self: normal; } .filter-menu { @@ -71,7 +72,7 @@ .filter-tag { font-size: 0.9em; background: #181b2daf; - border: solid 1px #105fb0; + border: solid 1px var(--primary); color: white; border-radius: 0.2rem; padding: 0.2em 0.5em; @@ -80,15 +81,15 @@ pointer-events: all; &.selected { - background-color: #105fb0; + background-color: var(--primary); } } &.any-mode { .filter-tag { - border: solid 1px #1a9436; + border: solid 1px var(--success); &.selected { - background-color: #1a9436; + background-color: var(--success); } } } @@ -114,15 +115,15 @@ } &.blue { - border: solid 1px #105fb0; + border: solid 1px var(--primary); &.active { - background: #105fb0; + background: var(--primary); } } &.green { - border: solid 1px #1a9436; + border: solid 1px var(--success); &.active { - background: #1a9436; + background: var(--success); } } &.yellow { @@ -136,7 +137,7 @@ :host-context(.block-overview-graph:hover) &, &:hover, &:active { .menu-toggle { opacity: 0.5; - background: #181b2d; + background: var(--stat-box-bg); &:hover { opacity: 1; diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.html b/frontend/src/app/components/block-health-graph/block-health-graph.component.html index cfbee79b6..86eb91862 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.html +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.html @@ -10,7 +10,7 @@
-
+
@@ -46,7 +46,7 @@
+ (chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss index 7b8154bae..98f689202 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -59,8 +60,3 @@ height: 100%; max-height: 270px; } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.ts b/frontend/src/app/components/block-health-graph/block-health-graph.component.ts index 8eef20d51..6a7168d6b 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.ts +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.ts @@ -21,7 +21,7 @@ import { StateService } from '../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -60,7 +60,7 @@ export class BlockHealthGraphComponent implements OnInit { } ngOnInit(): void { - this.seoService.setTitle($localize`:@@d7d5fcf50179ad70c938491c517efb82de2c8146:Block Health`); + this.seoService.setTitle($localize`:@@b1fa5b210c9670d49a6506f046d4a0c2797fd402:Block Health`); this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-health:See Bitcoin block health visualized over time. Block health is a measure of how many expected transactions were included in an actual mined block. Expected transactions are determined using Mempool's re-implementation of Bitcoin Core's transaction selection algorithm.`); this.miningWindowPreference = '24h';//this.miningService.getDefaultTimespan('24h'); this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); @@ -131,7 +131,7 @@ export class BlockHealthGraphComponent implements OnInit { borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', textStyle: { - color: '#b1b1b1', + color: 'var(--tooltip-grey)', align: 'left', }, borderColor: '#000', @@ -178,7 +178,7 @@ export class BlockHealthGraphComponent implements OnInit { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -290,7 +290,7 @@ export class BlockHealthGraphComponent implements OnInit { const now = new Date(); // @ts-ignore this.chartOptions.grid.bottom = 40; - this.chartOptions.backgroundColor = '#11131f'; + this.chartOptions.backgroundColor = 'var(--active-bg)'; this.chartInstance.setOption(this.chartOptions); download(this.chartInstance.getDataURL({ pixelRatio: 2, diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html index 2ef07d12c..471257230 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -2,10 +2,12 @@
-
-
-
not available
-
+ @if (!disableSpinner) { +
+
+
not available
+
+ } ; + themeChangedSubscription: Subscription; gl: WebGLRenderingContext; animationFrameRequest: number; @@ -72,6 +83,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On tooltipPosition: Position; readyNextFrame = false; + lastUpdate: number = 0; + pendingUpdate: { + count: number, + add: { [txid: string]: TransactionStripped }, + remove: { [txid: string]: string }, + change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } }, + direction?: string, + } = { + count: 0, + add: {}, + remove: {}, + change: {}, + direction: 'left', + }; searchText: string; searchSubscription: Subscription; @@ -84,6 +109,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On readonly ngZone: NgZone, readonly elRef: ElementRef, public stateService: StateService, + private themeService: ThemeService, ) { this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); @@ -102,6 +128,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.gl) { this.initCanvas(); this.resizeCanvas(); + this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => { + this.scene.setColorFunction(this.getColorFunction()); + }); } } } @@ -148,6 +177,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.canvas) { this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost); this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored); + this.themeChangedSubscription?.unsubscribe(); } } @@ -162,19 +192,21 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On destroy(): void { if (this.scene) { this.scene.destroy(); + this.clearUpdateQueue(); this.start(); } } // initialize the scene without any entry transition - setup(transactions: TransactionStripped[]): void { + setup(transactions: TransactionStripped[], sort: boolean = false): void { const filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false); if (filtersAvailable !== this.filtersAvailable) { this.setFilterFlags(); } this.filtersAvailable = filtersAvailable; if (this.scene) { - this.scene.setup(transactions); + this.clearUpdateQueue(); + this.scene.setup(transactions, sort); this.readyNextFrame = true; this.start(); this.updateSearchHighlight(); @@ -183,6 +215,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On enter(transactions: TransactionStripped[], direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.enter(transactions, direction); this.start(); this.updateSearchHighlight(); @@ -191,6 +224,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On exit(direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.exit(direction); this.start(); this.updateSearchHighlight(); @@ -199,13 +233,67 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On replace(transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.replace(transactions || [], direction, sort, startTime); this.start(); this.updateSearchHighlight(); } } + // collates deferred updates into a set of consistent pending changes + queueUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void { + for (const tx of add) { + this.pendingUpdate.add[tx.txid] = tx; + delete this.pendingUpdate.remove[tx.txid]; + delete this.pendingUpdate.change[tx.txid]; + } + for (const txid of remove) { + delete this.pendingUpdate.add[txid]; + this.pendingUpdate.remove[txid] = txid; + delete this.pendingUpdate.change[txid]; + } + for (const tx of change) { + if (this.pendingUpdate.add[tx.txid]) { + this.pendingUpdate.add[tx.txid].rate = tx.rate; + this.pendingUpdate.add[tx.txid].acc = tx.acc; + } else { + this.pendingUpdate.change[tx.txid] = tx; + } + } + this.pendingUpdate.direction = direction; + this.pendingUpdate.count++; + } + + deferredUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void { + this.queueUpdate(add, remove, change, direction); + this.applyQueuedUpdates(); + } + + applyQueuedUpdates(): void { + if (this.pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) { + this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), this.pendingUpdate.direction); + this.clearUpdateQueue(); + } + } + + clearUpdateQueue(): void { + this.pendingUpdate = { + count: 0, + add: {}, + remove: {}, + change: {}, + }; + this.lastUpdate = performance.now(); + } + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { + // merge any pending changes into this update + this.queueUpdate(add, remove, change); + this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), direction, resetLayout); + this.clearUpdateQueue(); + } + + applyUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { add = add.filter(tx => !this.scene.txs[tx.txid]); remove = remove.filter(txid => this.scene.txs[txid]); @@ -216,6 +304,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.scene.update(add, remove, change, direction, resetLayout); this.start(); + this.lastUpdate = performance.now(); this.updateSearchHighlight(); } } @@ -293,7 +382,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.start(); } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, - blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, + blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, theme: this.themeService, highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset, colorFunction: this.getColorFunction() }); this.start(); @@ -356,6 +445,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (!now) { now = performance.now(); } + this.applyQueuedUpdates(); // skip re-render if there's no change to the scene if (this.scene && this.gl) { /* SET UP SHADER UNIFORMS */ @@ -563,14 +653,27 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On getFilterColorFunction(flags: bigint, gradient: 'fee' | 'age'): ((tx: TxView) => Color) { return (tx: TxView) => { if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) { - return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)); + if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { + return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)); + } else { + return (gradient === 'age') ? ageColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)) : contrastColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)); + } } else { - return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction( - tx, - defaultColors.unmatchedfee, - unmatchedAuditColors, - this.relativeTime || (Date.now() / 1000) - ); + if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { + return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction( + tx, + defaultColors.unmatchedfee, + unmatchedAuditColors, + this.relativeTime || (Date.now() / 1000) + ); + } else { + return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : contrastColorFunction( + tx, + contrastColors.unmatchedfee, + unmatchedContrastAuditColors, + this.relativeTime || (Date.now() / 1000) + ); + } } }; } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index fb45e492b..4f07818a5 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -2,16 +2,18 @@ import { FastVertexArray } from './fast-vertex-array'; import TxView from './tx-view'; import { TransactionStripped } from '../../interfaces/node-api.interface'; import { Color, Position, Square, ViewUpdateParams } from './sprite-types'; -import { defaultColorFunction } from './utils'; +import { defaultColorFunction, contrastColorFunction } from './utils'; +import { ThemeService } from '../../services/theme.service'; export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; vertexArray: FastVertexArray; txs: { [key: string]: TxView }; getColor: ((tx: TxView) => Color) = defaultColorFunction; + theme: ThemeService; orientation: string; flip: boolean; - animationDuration: number = 900; + animationDuration: number = 1000; configAnimationOffset: number | null; animationOffset: number; highlightingEnabled: boolean; @@ -29,11 +31,11 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: + constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ) { - this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }); + this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { @@ -67,7 +69,7 @@ export default class BlockScene { } setColorFunction(colorFunction: ((tx: TxView) => Color) | null): void { - this.getColor = colorFunction || defaultColorFunction; + this.theme.theme === 'contrast' || this.theme.theme === 'bukele' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; this.updateAllColors(); } @@ -86,16 +88,19 @@ export default class BlockScene { } // set up the scene with an initial set of transactions, without any transition animation - setup(txs: TransactionStripped[]) { + setup(txs: TransactionStripped[], sort: boolean = false) { // clean up any old transactions Object.values(this.txs).forEach(tx => { tx.destroy(); delete this.txs[tx.txid]; }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); - txs.forEach(tx => { - const txView = new TxView(tx, this); - this.txs[tx.txid] = txView; + let txViews = txs.map(tx => new TxView(tx, this)); + if (sort) { + txViews = txViews.sort(feeRateDescending); + } + txViews.forEach(txView => { + this.txs[txView.txid] = txView; this.place(txView); this.saveGridToScreenPosition(txView); this.applyTxUpdate(txView, { @@ -177,7 +182,7 @@ export default class BlockScene { removed.forEach(tx => { tx.destroy(); }); - }, 1000); + }, (startTime - performance.now()) + this.animationDuration + 1000); if (resetLayout) { add.forEach(tx => { @@ -197,6 +202,7 @@ export default class BlockScene { this.txs[tx.txid].feerate = tx.rate || (this.txs[tx.txid].fee / this.txs[tx.txid].vsize); this.txs[tx.txid].rate = tx.rate; this.txs[tx.txid].dirty = true; + this.updateColor(this.txs[tx.txid], startTime, 50, true); } }); @@ -232,18 +238,19 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value)); } - private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: + private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ): void { - this.animationDuration = animationDuration || 1000; + this.animationDuration = animationDuration || this.animationDuration || 1000; this.configAnimationOffset = animationOffset; this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset; this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; - this.getColor = colorFunction || defaultColorFunction; + theme.theme === 'contrast' || theme.theme === 'bukele' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; + this.theme = theme; this.scene = { count: 0, diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 742c305f5..f612368f4 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -33,7 +33,7 @@ export default class TxView implements TransactionStripped { flags: number; bigintFlags?: bigint | null = 0b00000100_00000000_00000000_00000000n; time?: number; - status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated'; + status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'added_prioritized' | 'prioritized' | 'added_deprioritized' | 'deprioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated'; context?: 'projected' | 'actual'; scene?: BlockScene; diff --git a/frontend/src/app/components/block-overview-graph/utils.ts b/frontend/src/app/components/block-overview-graph/utils.ts index d15e87534..625029db0 100644 --- a/frontend/src/app/components/block-overview-graph/utils.ts +++ b/frontend/src/app/components/block-overview-graph/utils.ts @@ -1,4 +1,4 @@ -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { feeLevels, defaultMempoolFeeColors, contrastMempoolFeeColors } from '../../app.constants'; import { Color } from './sprite-types'; import TxView from './tx-view'; @@ -47,7 +47,7 @@ interface ColorPalette { // precomputed colors const defaultColors: { [key: string]: ColorPalette } = { fee: { - base: mempoolFeeColors.map(hexToColor), + base: defaultMempoolFeeColors.map(hexToColor), audit: [], marginal: [], baseLevel: (tx: TxView, rate: number) => feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1 @@ -71,8 +71,40 @@ export const defaultAuditColors = { censored: hexToColor('f344df'), missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), added: hexToColor('0099ff'), + added_prioritized: darken(desaturate(hexToColor('0099ff'), 0.15), 0.85), prioritized: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), - accelerated: hexToColor('8F5FF6'), + accelerated: hexToColor('8f5ff6'), +}; + +const contrastColors: { [key: string]: ColorPalette } = { + fee: { + base: contrastMempoolFeeColors.map(hexToColor), + audit: [], + marginal: [], + baseLevel: (tx: TxView, rate: number) => feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1 + }, +} +for (const key in contrastColors) { + const base = contrastColors[key].base; + contrastColors[key].audit = base.map((color) => darken(desaturate(color, 0.3), 0.9)); + contrastColors[key].marginal = base.map((color) => darken(desaturate(color, 0.8), 1.1)); + contrastColors['unmatched' + key] = { + base: contrastColors[key].base.map(c => setOpacity(c, 0.2)), + audit: contrastColors[key].audit.map(c => setOpacity(c, 0.2)), + marginal: contrastColors[key].marginal.map(c => setOpacity(c, 0.2)), + baseLevel: contrastColors[key].baseLevel, + }; +} + +export { contrastColors as contrastColors }; + +export const contrastAuditColors = { + censored: hexToColor('ffa8ff'), + missing: darken(desaturate(hexToColor('ffa8ff'), 0.3), 0.7), + added: hexToColor('00bb98'), + added_prioritized: darken(desaturate(hexToColor('00bb98'), 0.15), 0.85), + prioritized: darken(desaturate(hexToColor('00bb98'), 0.3), 0.7), + accelerated: hexToColor('8f5ff6'), }; export function defaultColorFunction( @@ -83,7 +115,7 @@ export function defaultColorFunction( ): Color { const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate const levelIndex = colors.baseLevel(tx, rate, relativeTime || (Date.now() / 1000)); - const levelColor = colors.base[levelIndex] || colors.base[mempoolFeeColors.length - 1]; + const levelColor = colors.base[levelIndex] || colors.base[defaultMempoolFeeColors.length - 1]; // Normal mode if (!tx.scene?.highlightingEnabled) { if (tx.acc) { @@ -100,21 +132,27 @@ export function defaultColorFunction( case 'missing': case 'sigop': case 'rbf': - return colors.marginal[levelIndex] || colors.marginal[mempoolFeeColors.length - 1]; + return colors.marginal[levelIndex] || colors.marginal[defaultMempoolFeeColors.length - 1]; case 'fresh': case 'freshcpfp': return auditColors.missing; case 'added': return auditColors.added; + case 'added_prioritized': + return auditColors.added_prioritized; case 'prioritized': return auditColors.prioritized; + case 'added_deprioritized': + return auditColors.added_prioritized; + case 'deprioritized': + return auditColors.prioritized; case 'selected': - return colors.marginal[levelIndex] || colors.marginal[mempoolFeeColors.length - 1]; + return colors.marginal[levelIndex] || colors.marginal[defaultMempoolFeeColors.length - 1]; case 'accelerated': return auditColors.accelerated; case 'found': if (tx.context === 'projected') { - return colors.audit[levelIndex] || colors.audit[mempoolFeeColors.length - 1]; + return colors.audit[levelIndex] || colors.audit[defaultMempoolFeeColors.length - 1]; } else { return levelColor; } @@ -127,17 +165,27 @@ export function defaultColorFunction( } } +export function contrastColorFunction( + tx: TxView, + colors: { base: Color[], audit: Color[], marginal: Color[], baseLevel: (tx: TxView, rate: number, time: number) => number } = contrastColors.fee, + auditColors: { [status: string]: Color } = contrastAuditColors, + relativeTime?: number, +): Color { + return defaultColorFunction(tx, colors, auditColors, relativeTime); +} + export function ageColorFunction( tx: TxView, colors: { base: Color[], audit: Color[], marginal: Color[], baseLevel: (tx: TxView, rate: number, time: number) => number } = defaultColors.fee, auditColors: { [status: string]: Color } = defaultAuditColors, relativeTime?: number, + theme?: string, ): Color { if (tx.acc || tx.status === 'accelerated') { return auditColors.accelerated; } - const color = defaultColorFunction(tx, colors, auditColors, relativeTime); + const color = theme !== 'contrast' && theme !== 'bukele' ? defaultColorFunction(tx, colors, auditColors, relativeTime) : contrastColorFunction(tx, colors, auditColors, relativeTime); const ageLevel = (!tx.time ? 0 : (0.8 * Math.tanh((1 / 15) * Math.log2((Math.max(1, 0.6 * ((relativeTime - tx.time) - 60))))))); return { @@ -146,4 +194,4 @@ export function ageColorFunction( b: color.b, a: color.a * (1 - ageLevel) }; -} \ No newline at end of file +} diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 505171f9d..f1f5bb3d4 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -3,7 +3,7 @@ class="block-overview-tooltip" [class.clickable]="clickable" [style.visibility]="tx ? 'visible' : 'hidden'" - [style.left]="tooltipPosition.x + 'px'" + [style.left]="getTooltipLeftPosition()" [style.top]="tooltipPosition.y + 'px'" > @@ -50,8 +50,8 @@ - - + @@ -75,6 +75,15 @@ Recently CPFP'dAddedPrioritized + + Added + Prioritized + + Deprioritized + + Added + Deprioritized + Marginal fee rateConflictAccelerated diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss index 51edee1e8..0b2c05948 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss @@ -1,16 +1,16 @@ .block-overview-tooltip { position: absolute; - background: rgba(#11131f, 0.95); + background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0,0,0,0.5); - color: #b1b1b1; + color: var(--tooltip-grey); display: flex; flex-direction: column; justify-content: space-between; padding: 10px 15px; text-align: left; min-width: 340px; - max-width: 340px; + max-width: 400px; pointer-events: none; z-index: 11; @@ -27,6 +27,9 @@ th, td { width: 70%; text-align: end; } + &.oobFees { + color: #905cf4; + } } .badge.badge-accelerated { @@ -41,7 +44,7 @@ th, td { flex-wrap: wrap; row-gap: 0.25em; margin-top: 0.2em; - max-width: 100%; + max-width: 310px; .badge { border-radius: 0.2rem; @@ -51,20 +54,20 @@ th, td { .filter-tag { background: #181b2daf; - border: solid 1px #105fb0; + border: solid 1px var(--primary); color: white; transition: background-color 300ms; &.matching { - background-color: #105fb0; + background-color: var(--primary); } } &.any-mode { .filter-tag { - border: solid 1px #1a9436; + border: solid 1px var(--success); &.matching { - background-color: #1a9436; + background-color: var(--success); } } } diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts index cdb4c7367..0a606983e 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -68,7 +68,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { this.effectiveRate = this.tx.rate; const txFlags = BigInt(this.tx.flags) || 0n; this.acceleration = this.tx.acc || (txFlags & TransactionFlags.acceleration); - this.hasEffectiveRate = Math.abs((this.fee / this.vsize) - this.effectiveRate) > 0.05 + this.hasEffectiveRate = this.tx.acc || !(Math.abs((this.fee / this.vsize) - this.effectiveRate) <= 0.1 && Math.abs((this.fee / Math.ceil(this.vsize)) - this.effectiveRate) <= 0.1) || (txFlags && (txFlags & (TransactionFlags.cpfp_child | TransactionFlags.cpfp_parent)) > 0n); this.filters = this.tx.flags ? toFilters(txFlags).filter(f => f.tooltip) : []; this.activeFilters = {} @@ -96,4 +96,8 @@ export class BlockOverviewTooltipComponent implements OnChanges { this.cd.markForCheck(); } } + + getTooltipLeftPosition(): string { + return window.innerWidth < 392 ? '-50px' : this.tooltipPosition.x + 'px'; + } } diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html index 10e6d304d..b239a0982 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html @@ -11,7 +11,7 @@ -
+
@@ -38,7 +38,7 @@
+ (chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss index 7b8154bae..98f689202 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -59,8 +60,3 @@ height: 100%; max-height: 270px; } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts index fe8efa9c7..63a543674 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts @@ -23,7 +23,7 @@ import { StateService } from '../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -150,7 +150,7 @@ export class BlockRewardsGraphComponent implements OnInit { borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', textStyle: { - color: '#b1b1b1', + color: 'var(--tooltip-grey)', align: 'left', }, borderColor: '#000', @@ -219,7 +219,7 @@ export class BlockRewardsGraphComponent implements OnInit { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -315,7 +315,7 @@ export class BlockRewardsGraphComponent implements OnInit { const now = new Date(); // @ts-ignore this.chartOptions.grid.bottom = 40; - this.chartOptions.backgroundColor = '#11131f'; + this.chartOptions.backgroundColor = 'var(--active-bg)'; this.chartInstance.setOption(this.chartOptions); download(this.chartInstance.getDataURL({ pixelRatio: 2, diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html index c21a4bebb..521357c4b 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.html @@ -9,7 +9,7 @@
-
+
@@ -45,7 +45,7 @@
+ (chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss index 7b8154bae..98f689202 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -59,8 +60,3 @@ height: 100%; max-height: 270px; } - -.disabled { - pointer-events: none; - opacity: 0.5; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts index 76d6e8216..b0069dca2 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts @@ -21,7 +21,7 @@ import { StateService } from '../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -146,7 +146,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', textStyle: { - color: '#b1b1b1', + color: 'var(--tooltip-grey)', align: 'left', }, borderColor: '#000', @@ -230,7 +230,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, @@ -252,7 +252,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { symbol: 'none', lineStyle: { type: 'solid', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 1, width: 1, }, @@ -342,7 +342,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { const now = new Date(); // @ts-ignore this.chartOptions.grid.bottom = 40; - this.chartOptions.backgroundColor = '#11131f'; + this.chartOptions.backgroundColor = 'var(--active-bg)'; this.chartInstance.setOption(this.chartOptions); download(this.chartInstance.getDataURL({ pixelRatio: 2, diff --git a/frontend/src/app/components/block-view/block-view.component.scss b/frontend/src/app/components/block-view/block-view.component.scss index 782d416d8..746ac654d 100644 --- a/frontend/src/app/components/block-view/block-view.component.scss +++ b/frontend/src/app/components/block-view/block-view.component.scss @@ -1,7 +1,7 @@ .block-wrapper { width: 100vw; height: 100vh; - background: #181b2d; + background: var(--stat-box-bg); } .block-container { diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html index 2bb749e80..56fa8886e 100644 --- a/frontend/src/app/components/block/block-preview.component.html +++ b/frontend/src/app/components/block/block-preview.component.html @@ -52,9 +52,9 @@
@@ -325,69 +325,49 @@ >Details -
-

- - {{ i }} transaction - {{ i }} transactions -

- - -
-
- - - - -
- - Error loading data. - -
-
-
- - -
- - + @defer (on viewport) { + + } @placeholder { +
+
+

+ + {{ i }} transaction + {{ i }} transactions +

+ +
+
+
+
-
-
-
+
- - -
-
-
- - -
-
- +
+
+
+ +
+
+ + + +
- - + }

- Error loading data. + Error loading block data. - -
- -
-
-
@@ -431,7 +411,7 @@
+ + + + + + + + + } + @case ('blocks') { +
Effective fee rateAccelerated fee rate + Accelerated fee rate
Miner - - {{ block?.extras.pool.name }} + + + {{ block.extras.pool.name }} diff --git a/frontend/src/app/components/block/block-preview.component.scss b/frontend/src/app/components/block/block-preview.component.scss index 4469f7f60..cc1546ede 100644 --- a/frontend/src/app/components/block/block-preview.component.scss +++ b/frontend/src/app/components/block/block-preview.component.scss @@ -65,3 +65,11 @@ .badge { transition: none; } + +.pool-logo { + width: 25px; + height: 25px; + position: relative; + top: -1px; + margin-right: 2px; +} diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts index 3e1d9b409..72da96818 100644 --- a/frontend/src/app/components/block/block-preview.component.ts +++ b/frontend/src/app/components/block/block-preview.component.ts @@ -136,7 +136,12 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { return of(transactions); }) ), - this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) + this.stateService.env.ACCELERATOR === true && block.height > 819500 + ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) + .pipe(catchError(() => { + return of([]); + })) + : of([]) ]); } ), diff --git a/frontend/src/app/components/block/block-transactions.component.html b/frontend/src/app/components/block/block-transactions.component.html new file mode 100644 index 000000000..c732ea41e --- /dev/null +++ b/frontend/src/app/components/block/block-transactions.component.html @@ -0,0 +1,53 @@ +
+

+ + {{ i }} transaction + {{ i }} transactions +

+ +
+
+ + + + +
+ + Error loading data. + +
+
+
+ + +
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+
+ + +
+ +
+
+ + diff --git a/frontend/src/app/components/block/block-transactions.component.scss b/frontend/src/app/components/block/block-transactions.component.scss new file mode 100644 index 000000000..d1ade512b --- /dev/null +++ b/frontend/src/app/components/block/block-transactions.component.scss @@ -0,0 +1,37 @@ +.block-tx-title { + display: flex; + justify-content: space-between; + flex-direction: column; + margin-top: -15px; + position: relative; + @media (min-width: 550px) { + margin-top: 1rem; + flex-direction: row; + } + h2 { + line-height: 1; + margin: 0; + position: relative; + padding-bottom: 10px; + @media (min-width: 550px) { + padding-bottom: 0px; + align-self: end; + } + } +} + +.tx-skeleton { + margin-top: 10px; + margin-bottom: 10px; + .header-bg { + &:first-child { + padding: 10px; + margin-bottom: 10px; + } + &:nth-child(2) { + .row { + height: 107px; + } + } + } +} diff --git a/frontend/src/app/components/block/block-transactions.component.ts b/frontend/src/app/components/block/block-transactions.component.ts new file mode 100644 index 000000000..c0cda6c4f --- /dev/null +++ b/frontend/src/app/components/block/block-transactions.component.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Transaction, Vout } from '../../interfaces/electrs.interface'; +import { Observable, Subscription, catchError, combineLatest, map, of, startWith, switchMap, tap } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { PreloadService } from '../../services/preload.service'; + +@Component({ + selector: 'app-block-transactions', + templateUrl: './block-transactions.component.html', + styleUrl: './block-transactions.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockTransactionsComponent implements OnInit { + @Input() txCount: number; + @Input() timestamp: number; + @Input() blockHash: string; + @Input() previousBlockHash: string; + @Input() block$: Observable; + @Input() paginationMaxSize: number; + @Output() blockReward = new EventEmitter(); + + itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; + page = 1; + + transactions$: Observable; + isLoadingTransactions = true; + transactionsError: any = null; + transactionSubscription: Subscription; + txsLoadingStatus$: Observable; + nextBlockTxListSubscription: Subscription; + + constructor( + private stateService: StateService, + private route: ActivatedRoute, + private router: Router, + private electrsApiService: ElectrsApiService, + ) { } + + ngOnInit(): void { + this.transactions$ = combineLatest([this.block$, this.route.queryParams]).pipe( + tap(([_, queryParams]) => { + this.page = +queryParams['page'] || 1; + }), + switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage) + .pipe( + startWith(null), + catchError((err) => { + this.transactionsError = err; + return of([]); + })) + ), + tap((transactions: Transaction[]) => { + // The block API doesn't contain the block rewards on Liquid + if (this.stateService.isLiquid() && transactions && transactions[0] && transactions[0].vin[0].is_coinbase) { + const blockReward = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000; + this.blockReward.emit(blockReward); + } + }) + ); + + this.txsLoadingStatus$ = this.route.paramMap + .pipe( + switchMap(() => this.stateService.loadingIndicators$), + map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0) + ); + } + + pageChange(page: number, target: HTMLElement): void { + target.scrollIntoView(); // works for chrome + this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' }); + } +} diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index d2f84116c..1dd9d8a8d 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -181,8 +181,8 @@
Miner - + + {{ block.extras.pool.name }} - + + {{ blockAudit.feeDelta < 0 ? '+' : '' }}{{ (-blockAudit.feeDelta * 100) | amountShortener: 2 }}% diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index bad8fa52e..fe5318375 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -21,25 +21,6 @@ } } -.qr-wrapper { - background-color: #FFF; - padding: 10px; - padding-bottom: 5px; - display: inline-block; -} - -.qrcode-col { - text-align: center; -} - -.qrcode-col > div { - margin: 20px auto 5px; - @media (min-width: 768px) { - text-align: center; - margin: auto; - } -} - .fiat { display: block; font-size: 13px; @@ -100,19 +81,7 @@ h1 { } } -.address-link { - line-height: 26px; - margin-left: 0px; - top: 14px; - position: relative; - display: flex; - flex-direction: row; - @media (min-width: 768px) { - line-height: 38px; - } -} - -.row{ +.row { flex-direction: column; @media (min-width: 768px) { flex-direction: row; @@ -140,28 +109,6 @@ h1 { margin-right: .5em; } -.block-tx-title { - display: flex; - justify-content: space-between; - flex-direction: column; - margin-top: -15px; - position: relative; - @media (min-width: 550px) { - margin-top: 1rem; - flex-direction: row; - } - h2 { - line-height: 1; - margin: 0; - position: relative; - padding-bottom: 10px; - @media (min-width: 550px) { - padding-bottom: 0px; - align-self: end; - } - } -} - .grow { flex-grow: 1; } @@ -175,9 +122,7 @@ h1 { } a { - color: #1ad8f4; &:hover, &:focus { - color: #09a3ba; display: inline-block; } } @@ -206,22 +151,6 @@ h1 { } } -.tx-skeleton { - margin-top: 10px; - margin-bottom: 10px; - .header-bg { - &:first-child { - padding: 10px; - margin-bottom: 10px; - } - &:nth-child(2) { - .row { - height: 107px; - } - } - } -} - .chart-container{ margin: 20px auto; @media (min-width: 768px) { @@ -254,7 +183,7 @@ h1 { cursor: pointer; &.active { - background: #24273e; + background: var(--box-bg); } &.active, &:hover { @@ -305,3 +234,49 @@ h1 { .graph-col { flex-grow: 1.11; } + +.block-tx-title { + display: flex; + justify-content: space-between; + flex-direction: column; + margin-top: -15px; + position: relative; + @media (min-width: 550px) { + margin-top: 1rem; + flex-direction: row; + } + h2 { + line-height: 1; + margin: 0; + position: relative; + padding-bottom: 10px; + @media (min-width: 550px) { + padding-bottom: 0px; + align-self: end; + } + } +} + +.tx-skeleton { + margin-top: 10px; + margin-bottom: 10px; + .header-bg { + &:first-child { + padding: 10px; + margin-bottom: 10px; + } + &:nth-child(2) { + .row { + height: 107px; + } + } + } +} + +.pool-logo { + width: 15px; + height: 15px; + position: relative; + top: -1px; + margin-right: 2px; +} diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 13b0ecd76..5cba85e90 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -1,15 +1,14 @@ -import { Component, OnInit, OnDestroy, ViewChildren, QueryList, Inject, PLATFORM_ID, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core'; import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators'; -import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from '../../services/seo.service'; import { WebsocketService } from '../../services/websocket.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; -import { AccelerationInfo, BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; +import { Acceleration, BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { detectWebGL } from '../../shared/graphs.utils'; @@ -17,6 +16,8 @@ import { seoDescriptionNetwork } from '../../shared/common.utils'; import { PriceService, Price } from '../../services/price.service'; import { CacheService } from '../../services/cache.service'; import { ServicesApiServices } from '../../services/services-api.service'; +import { PreloadService } from '../../services/preload.service'; +import { identifyPrioritizedTransactions } from '../../shared/transaction.utils'; @Component({ selector: 'app-block', @@ -42,23 +43,18 @@ export class BlockComponent implements OnInit, OnDestroy { isLoadingBlock = true; latestBlock: BlockExtended; latestBlocks: BlockExtended[] = []; - transactions: Transaction[]; oobFees: number = 0; - isLoadingTransactions = true; strippedTransactions: TransactionStripped[]; + accelerations: Acceleration[]; overviewTransitionDirection: string; isLoadingOverview = true; error: any; blockSubsidy: number; fees: number; - paginationMaxSize: number; - page = 1; - itemsPerPage: number; - txsLoadingStatus$: Observable; + block$: Observable; showDetails = false; showPreviousBlocklink = true; showNextBlocklink = true; - transactionsError: any = null; overviewError: any = null; webGlEnabled = true; auditParamEnabled: boolean = false; @@ -69,20 +65,17 @@ export class BlockComponent implements OnInit, OnDestroy { isMobile = window.innerWidth <= 767.98; hoverTx: string; numMissing: number = 0; + paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; numUnexpected: number = 0; mode: 'projected' | 'actual' = 'projected'; - transactionSubscription: Subscription; overviewSubscription: Subscription; - auditSubscription: Subscription; + accelerationsSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; cacheBlocksSubscription: Subscription; networkChangedSubscription: Subscription; queryParamsSubscription: Subscription; - nextBlockSubscription: Subscription = undefined; - nextBlockSummarySubscription: Subscription = undefined; - nextBlockTxListSubscription: Subscription = undefined; timeLtrSubscription: Subscription; timeLtr: boolean; childChangeSubscription: Subscription; @@ -109,16 +102,14 @@ export class BlockComponent implements OnInit, OnDestroy { private cacheService: CacheService, private servicesApiService: ServicesApiServices, private cd: ChangeDetectorRef, - @Inject(PLATFORM_ID) private platformId: Object, + private preloadService: PreloadService, ) { this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); } - ngOnInit() { + ngOnInit(): void { this.websocketService.want(['blocks', 'mempool-blocks']); - this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; this.network = this.stateService.network; - this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; @@ -139,12 +130,6 @@ export class BlockComponent implements OnInit, OnDestroy { }); } - this.txsLoadingStatus$ = this.route.paramMap - .pipe( - switchMap(() => this.stateService.loadingIndicators$), - map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0) - ); - this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { this.loadedCacheBlock(block); }); @@ -172,11 +157,10 @@ export class BlockComponent implements OnInit, OnDestroy { } }); - const block$ = this.route.paramMap.pipe( + this.block$ = this.route.paramMap.pipe( switchMap((params: ParamMap) => { const blockHash: string = params.get('id') || ''; this.block = undefined; - this.page = 1; this.error = undefined; this.fees = undefined; this.oobFees = 0; @@ -202,6 +186,9 @@ export class BlockComponent implements OnInit, OnDestroy { } else { this.isLoadingBlock = true; this.isLoadingOverview = true; + this.strippedTransactions = undefined; + this.blockAudit = undefined; + this.accelerations = undefined; let blockInCache: BlockExtended; if (isBlockHeight) { @@ -254,16 +241,11 @@ export class BlockComponent implements OnInit, OnDestroy { } }), tap((block: BlockExtended) => { - if (block.height > 0) { - // Preload previous block summary (execute the http query so the response will be cached) - this.unsubscribeNextBlockSubscriptions(); - setTimeout(() => { - this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); - this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); - if (this.auditSupported) { - this.apiService.getBlockAudit$(block.previousblockhash); - } - }, 100); + if (block.previousblockhash) { + this.preloadService.block$.next(block.previousblockhash); + if (this.auditSupported) { + this.preloadService.blockAudit$.next(block.previousblockhash); + } } this.updateAuditAvailableFromBlockHeight(block.height); this.block = block; @@ -288,9 +270,6 @@ export class BlockComponent implements OnInit, OnDestroy { this.fees = block.extras.reward / 100000000 - this.blockSubsidy; } this.stateService.markBlock$.next({ blockHeight: this.blockHeight }); - this.isLoadingTransactions = true; - this.transactions = null; - this.transactionsError = null; this.isLoadingOverview = true; this.overviewError = null; @@ -304,31 +283,8 @@ export class BlockComponent implements OnInit, OnDestroy { throttleTime(300, asyncScheduler, { leading: true, trailing: true }), shareReplay(1) ); - this.transactionSubscription = combineLatest([block$, this.route.queryParams]).pipe( - tap(([_, queryParams]) => this.page = +queryParams['page'] || 1), - switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage) - .pipe( - catchError((err) => { - this.transactionsError = err; - return of([]); - })) - ), - ) - .subscribe((transactions: Transaction[]) => { - if (this.fees === undefined && transactions[0]) { - this.fees = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000 - this.blockSubsidy; - } - this.transactions = transactions; - this.isLoadingTransactions = false; - this.cd.markForCheck(); - }, - (error) => { - this.error = error; - this.isLoadingBlock = false; - this.isLoadingOverview = false; - }); - this.overviewSubscription = block$.pipe( + this.overviewSubscription = this.block$.pipe( switchMap((block) => { return forkJoin([ this.apiService.getStrippedBlockTransactions$(block.id) @@ -344,153 +300,46 @@ export class BlockComponent implements OnInit, OnDestroy { this.overviewError = err; return of(null); }) - ), - this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) + ) ]); }) ) - .subscribe(([transactions, blockAudit, accelerations]) => { + .subscribe(([transactions, blockAudit]) => { if (transactions) { this.strippedTransactions = transactions; } else { this.strippedTransactions = []; } + this.blockAudit = blockAudit; - const acceleratedInBlock = {}; - for (const acc of accelerations) { - acceleratedInBlock[acc.txid] = acc; - } - for (const tx of transactions) { - if (acceleratedInBlock[tx.txid]) { - tx.acc = true; - } - } - - this.blockAudit = null; - if (transactions && blockAudit) { - const inTemplate = {}; - const inBlock = {}; - const isAdded = {}; - const isPrioritized = {}; - const isCensored = {}; - const isMissing = {}; - const isSelected = {}; - const isFresh = {}; - const isSigop = {}; - const isRbf = {}; - const isAccelerated = {}; - this.numMissing = 0; - this.numUnexpected = 0; - - if (blockAudit?.template) { - for (const tx of blockAudit.template) { - inTemplate[tx.txid] = true; - if (tx.acc) { - isAccelerated[tx.txid] = true; - } - } - for (const tx of transactions) { - inBlock[tx.txid] = true; - } - for (const txid of blockAudit.addedTxs) { - isAdded[txid] = true; - } - for (const txid of blockAudit.prioritizedTxs || []) { - isPrioritized[txid] = true; - } - for (const txid of blockAudit.missingTxs) { - isCensored[txid] = true; - } - for (const txid of blockAudit.freshTxs || []) { - isFresh[txid] = true; - } - for (const txid of blockAudit.sigopTxs || []) { - isSigop[txid] = true; - } - for (const txid of blockAudit.fullrbfTxs || []) { - isRbf[txid] = true; - } - for (const txid of blockAudit.acceleratedTxs || []) { - isAccelerated[txid] = true; - } - // set transaction statuses - for (const tx of blockAudit.template) { - tx.context = 'projected'; - if (isCensored[tx.txid]) { - tx.status = 'censored'; - } else if (inBlock[tx.txid]) { - tx.status = 'found'; - } else { - if (isFresh[tx.txid]) { - if (tx.rate - (tx.fee / tx.vsize) >= 0.1) { - tx.status = 'freshcpfp'; - } else { - tx.status = 'fresh'; - } - } else if (isSigop[tx.txid]) { - tx.status = 'sigop'; - } else if (isRbf[tx.txid]) { - tx.status = 'rbf'; - } else { - tx.status = 'missing'; - } - isMissing[tx.txid] = true; - this.numMissing++; - } - if (isAccelerated[tx.txid]) { - tx.status = 'accelerated'; - } - } - for (const [index, tx] of transactions.entries()) { - tx.context = 'actual'; - if (index === 0) { - tx.status = null; - } else if (isAdded[tx.txid]) { - tx.status = 'added'; - } else if (isPrioritized[tx.txid]) { - tx.status = 'prioritized'; - } else if (inTemplate[tx.txid]) { - tx.status = 'found'; - } else if (isRbf[tx.txid]) { - tx.status = 'rbf'; - } else { - tx.status = 'selected'; - isSelected[tx.txid] = true; - this.numUnexpected++; - } - if (isAccelerated[tx.txid]) { - tx.status = 'accelerated'; - } - } - for (const tx of transactions) { - inBlock[tx.txid] = true; - } - - blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - (this.block?.extras.totalFees + this.oobFees)) / blockAudit.expectedFees : 0; - blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block?.weight) / blockAudit.expectedWeight : 0; - blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block?.tx_count) / blockAudit.template.length : 0; - this.blockAudit = blockAudit; - this.setAuditAvailable(true); - } else { - this.setAuditAvailable(false); - } - } else { - this.setAuditAvailable(false); - } - + this.setupBlockAudit(); this.isLoadingOverview = false; - this.setupBlockGraphs(); - this.cd.markForCheck(); }); - this.oobSubscription = block$.pipe( + this.accelerationsSubscription = this.block$.pipe( + switchMap((block) => { + return this.stateService.env.ACCELERATOR === true && block.height > 819500 + ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) + .pipe(catchError(() => { + return of([]); + })) + : of([]); + }) + ).subscribe((accelerations) => { + this.accelerations = accelerations; + if (accelerations.length) { + this.setupBlockAudit(); + } + }); + + this.oobSubscription = this.block$.pipe( filter(() => this.stateService.env.PUBLIC_ACCELERATIONS === true && this.stateService.network === ''), switchMap((block) => this.apiService.getAccelerationsByHeight$(block.height) .pipe( map(accelerations => { return { block, accelerations }; }), - catchError((err) => { + catchError(() => { return of({ block, accelerations: [] }); })) ), @@ -545,7 +394,7 @@ export class BlockComponent implements OnInit, OnDestroy { if (this.priceSubscription) { this.priceSubscription.unsubscribe(); } - this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, block$]).pipe( + this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, this.block$]).pipe( switchMap(([currency, block]) => { return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe( tap((price) => { @@ -562,52 +411,27 @@ export class BlockComponent implements OnInit, OnDestroy { }); } - ngOnDestroy() { + ngOnDestroy(): void { this.stateService.markBlock$.next({}); - this.transactionSubscription?.unsubscribe(); this.overviewSubscription?.unsubscribe(); - this.auditSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe(); this.blocksSubscription?.unsubscribe(); this.cacheBlocksSubscription?.unsubscribe(); this.networkChangedSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe(); this.timeLtrSubscription?.unsubscribe(); - this.auditSubscription?.unsubscribe(); - this.unsubscribeNextBlockSubscriptions(); this.childChangeSubscription?.unsubscribe(); this.priceSubscription?.unsubscribe(); this.oobSubscription?.unsubscribe(); } - unsubscribeNextBlockSubscriptions() { - if (this.nextBlockSubscription !== undefined) { - this.nextBlockSubscription.unsubscribe(); - } - if (this.nextBlockSummarySubscription !== undefined) { - this.nextBlockSummarySubscription.unsubscribe(); - } - if (this.nextBlockTxListSubscription !== undefined) { - this.nextBlockTxListSubscription.unsubscribe(); - } - } - // TODO - Refactor this.fees/this.reward for liquid because it is not // used anymore on Bitcoin networks (we use block.extras directly) - setBlockSubsidy() { + setBlockSubsidy(): void { this.blockSubsidy = 0; } - pageChange(page: number, target: HTMLElement) { - const start = (page - 1) * this.itemsPerPage; - this.isLoadingTransactions = true; - this.transactions = null; - this.transactionsError = null; - target.scrollIntoView(); // works for chrome - this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' }); - } - - toggleShowDetails() { + toggleShowDetails(): void { if (this.showDetails) { this.showDetails = false; this.router.navigate([], { @@ -639,7 +463,7 @@ export class BlockComponent implements OnInit, OnDestroy { return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; } - navigateToPreviousBlock() { + navigateToPreviousBlock(): void { if (!this.block) { return; } @@ -648,13 +472,13 @@ export class BlockComponent implements OnInit, OnDestroy { block ? block.id : this.block.previousblockhash], { state: { data: { block, blockHeight: this.nextBlockHeight - 2 } } }); } - navigateToNextBlock() { + navigateToNextBlock(): void { const block = this.latestBlocks.find((b) => b.height === this.nextBlockHeight); this.router.navigate([this.relativeUrlPipe.transform('/block/'), block ? block.id : this.nextBlockHeight], { state: { data: { block, blockHeight: this.nextBlockHeight } } }); } - setNextAndPreviousBlockLink(){ + setNextAndPreviousBlockLink(): void { if (this.latestBlock) { if (!this.blockHeight){ this.showPreviousBlocklink = false; @@ -669,6 +493,183 @@ export class BlockComponent implements OnInit, OnDestroy { } } + setupBlockAudit(): void { + const transactions = this.strippedTransactions || []; + const blockAudit = this.blockAudit; + const accelerations = this.accelerations || []; + + const acceleratedInBlock = {}; + for (const acc of accelerations) { + if (acc.pools?.some(pool => pool === this.block?.extras?.pool.id)) { + acceleratedInBlock[acc.txid] = acc; + } + } + + for (const tx of transactions) { + if (acceleratedInBlock[tx.txid]) { + tx.acc = true; + const acceleration = acceleratedInBlock[tx.txid]; + const boostCost = acceleration.boostCost || acceleration.bidBoost; + const acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize; + if (acceleratedFeeRate > tx.rate) { + tx.rate = acceleratedFeeRate; + } + } else { + tx.acc = false; + } + } + + if (transactions && blockAudit) { + const inTemplate = {}; + const inBlock = {}; + const isUnseen = {}; + const isAdded = {}; + const isPrioritized = {}; + const isDeprioritized = {}; + const isCensored = {}; + const isMissing = {}; + const isSelected = {}; + const isFresh = {}; + const isSigop = {}; + const isRbf = {}; + const isAccelerated = {}; + this.numMissing = 0; + this.numUnexpected = 0; + + if (blockAudit?.template) { + // augment with locally calculated *de*prioritized transactions if possible + const { prioritized, deprioritized } = identifyPrioritizedTransactions(transactions); + // but if the local calculation produces returns unexpected results, don't use it + let useLocalDeprioritized = deprioritized.length < (transactions.length * 0.1); + for (const tx of prioritized) { + if (!isPrioritized[tx] && !isAccelerated[tx]) { + useLocalDeprioritized = false; + break; + } + } + + for (const tx of blockAudit.template) { + inTemplate[tx.txid] = true; + if (tx.acc) { + isAccelerated[tx.txid] = true; + } + } + for (const tx of transactions) { + inBlock[tx.txid] = true; + } + for (const txid of blockAudit.unseenTxs || []) { + isUnseen[txid] = true; + } + for (const txid of blockAudit.addedTxs) { + isAdded[txid] = true; + } + for (const txid of blockAudit.prioritizedTxs) { + isPrioritized[txid] = true; + } + if (useLocalDeprioritized) { + for (const txid of deprioritized || []) { + isDeprioritized[txid] = true; + } + } + for (const txid of blockAudit.missingTxs) { + isCensored[txid] = true; + } + for (const txid of blockAudit.freshTxs || []) { + isFresh[txid] = true; + } + for (const txid of blockAudit.sigopTxs || []) { + isSigop[txid] = true; + } + for (const txid of blockAudit.fullrbfTxs || []) { + isRbf[txid] = true; + } + for (const txid of blockAudit.acceleratedTxs || []) { + isAccelerated[txid] = true; + } + // set transaction statuses + for (const tx of blockAudit.template) { + tx.context = 'projected'; + if (isCensored[tx.txid]) { + tx.status = 'censored'; + } else if (inBlock[tx.txid]) { + tx.status = 'found'; + } else { + if (isFresh[tx.txid]) { + if (tx.rate - (tx.fee / tx.vsize) >= 0.1) { + tx.status = 'freshcpfp'; + } else { + tx.status = 'fresh'; + } + } else if (isSigop[tx.txid]) { + tx.status = 'sigop'; + } else if (isRbf[tx.txid]) { + tx.status = 'rbf'; + } else { + tx.status = 'missing'; + } + isMissing[tx.txid] = true; + this.numMissing++; + } + if (isAccelerated[tx.txid]) { + tx.status = 'accelerated'; + } + } + let anySeen = false; + for (let index = transactions.length - 1; index >= 0; index--) { + const tx = transactions[index]; + tx.context = 'actual'; + if (index === 0) { + tx.status = null; + } else if (isPrioritized[tx.txid]) { + if (isAdded[tx.txid] || (blockAudit.version > 0 && isUnseen[tx.txid])) { + tx.status = 'added_prioritized'; + } else { + tx.status = 'prioritized'; + } + } else if (isDeprioritized[tx.txid]) { + if (isAdded[tx.txid] || (blockAudit.version > 0 && isUnseen[tx.txid])) { + tx.status = 'added_deprioritized'; + } else { + tx.status = 'deprioritized'; + } + } else if (isAdded[tx.txid] && (blockAudit.version === 0 || isUnseen[tx.txid])) { + tx.status = 'added'; + } else if (inTemplate[tx.txid]) { + anySeen = true; + tx.status = 'found'; + } else if (isRbf[tx.txid]) { + tx.status = 'rbf'; + } else if (isUnseen[tx.txid] && anySeen) { + tx.status = 'added'; + } else { + tx.status = 'selected'; + isSelected[tx.txid] = true; + this.numUnexpected++; + } + if (isAccelerated[tx.txid]) { + tx.status = 'accelerated'; + } + } + for (const tx of transactions) { + inBlock[tx.txid] = true; + } + + blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - (this.block?.extras.totalFees + this.oobFees)) / blockAudit.expectedFees : 0; + blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block?.weight) / blockAudit.expectedWeight : 0; + blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block?.tx_count) / blockAudit.template.length : 0; + this.blockAudit = blockAudit; + this.setAuditAvailable(true); + } else { + this.setAuditAvailable(false); + } + } else { + this.setAuditAvailable(false); + } + + this.setupBlockGraphs(); + this.cd.markForCheck(); + } + setupBlockGraphs(): void { if (this.blockAudit || this.strippedTransactions) { this.blockGraphProjected.forEach(graph => { @@ -686,11 +687,12 @@ export class BlockComponent implements OnInit, OnDestroy { } } - onResize(event: any): void { - const isMobile = event.target.innerWidth <= 767.98; + onResize(event: Event): void { + const target = event.target as Window; + const isMobile = target.innerWidth <= 767.98; const changed = isMobile !== this.isMobile; this.isMobile = isMobile; - this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; + this.paginationMaxSize = target.innerWidth < 670 ? 3 : 5; if (changed) { this.changeMode(this.mode); @@ -732,11 +734,11 @@ export class BlockComponent implements OnInit, OnDestroy { this.stateService.hideAudit.next(this.auditModeEnabled); this.route.queryParams.subscribe(params => { - let queryParams = { ...params }; + const queryParams = { ...params }; delete queryParams['audit']; let newUrl = this.router.url.split('?')[0]; - let queryString = new URLSearchParams(queryParams).toString(); + const queryString = new URLSearchParams(queryParams).toString(); if (queryString) { newUrl += '?' + queryString; } @@ -814,4 +816,10 @@ export class BlockComponent implements OnInit, OnDestroy { this.block.canonical = block.id; } } + + updateBlockReward(blockReward: number): void { + if (this.fees === undefined) { + this.fees = blockReward; + } + } } \ No newline at end of file diff --git a/frontend/src/app/components/block/block.module.ts b/frontend/src/app/components/block/block.module.ts index d6991c68a..661e52dcf 100644 --- a/frontend/src/app/components/block/block.module.ts +++ b/frontend/src/app/components/block/block.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Routes, RouterModule } from '@angular/router'; import { BlockComponent } from './block.component'; +import { BlockTransactionsComponent } from './block-transactions.component'; import { SharedModule } from '../../shared/shared.module'; const routes: Routes = [ @@ -32,6 +33,7 @@ export class BlockRoutingModule { } ], declarations: [ BlockComponent, + BlockTransactionsComponent, ] }) export class BlockModule { } diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index ac29524bb..a60e1db0a 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -12,6 +12,7 @@ class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}" [class.offscreen]="!static && count && i >= count" id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]" + [style]="blockTransformation" [class.blink-bg]="isSpecial(block.height)">   @@ -40,7 +41,7 @@ -
@@ -58,10 +59,11 @@ - @@ -84,7 +86,7 @@
+ [ngStyle]="{'left': arrowLeftPx + 8 + 'px' }"> diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index c1cc6809d..b8de4f2ca 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -63,7 +63,7 @@ .fee-span { font-size: 11px; margin-bottom: 5px; - color: #fff000; + color: var(--yellow); } .transaction-count { @@ -117,20 +117,20 @@ } .black-background { - background-color: #11131f; + background-color: var(--active-bg); z-index: 100; position: relative; } #arrow-up { position: relative; - left: 30px; - top: 140px; + left: calc(var(--block-size) * 0.6); + top: calc(var(--block-size) * 1.28); width: 0; height: 0; - border-left: 35px solid transparent; - border-right: 35px solid transparent; - border-bottom: 35px solid #FFF; + border-left: calc(var(--block-size) * 0.2) solid transparent; + border-right: calc(var(--block-size) * 0.2) solid transparent; + border-bottom: calc(var(--block-size) * 0.2) solid var(--fg); } .flashing { @@ -144,7 +144,7 @@ } .loading .bitcoin-block.mined-block { - background: #2d3348; + background: var(--secondary); } @keyframes opacityPulse { @@ -157,17 +157,20 @@ position: relative; top: 15px; z-index: 101; + color: #FFF; +} + +.pool-logo { + width: 15px; + height: 15px; + position: relative; + top: -1px; + margin-right: 2px; } .animated { transition: all 0.15s ease-in-out; -} -.show { - opacity: 1; -} -.hide { - opacity: 0.4; - pointer-events : none; + white-space: nowrap; } .time-ltr { diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 5141f4de9..1a7598079 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { Observable, Subscription, delay, filter, tap } from 'rxjs'; import { StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; import { BlockExtended } from '../../interfaces/node-api.interface'; @@ -45,7 +45,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { markBlockSubscription: Subscription; txConfirmedSubscription: Subscription; loadingBlocks$: Observable; - showMiningInfo$: BehaviorSubject = new BehaviorSubject(false); + showMiningInfoSubscription: Subscription; + blockDisplayModeSubscription: Subscription; + blockDisplayMode: 'size' | 'fees'; + blockTransformation = {}; blockStyles = []; emptyBlockStyles = []; interval: any; @@ -63,11 +66,12 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { blockPadding: number = 30; gradientColors = { - '': ['#9339f4', '#105fb0'], - liquid: ['#116761', '#183550'], - 'liquidtestnet': ['#494a4a', '#272e46'], - testnet: ['#1d486f', '#183550'], - signet: ['#6f1d5d', '#471850'], + '': ['var(--mainnet-alt)', 'var(--primary)'], + liquid: ['var(--liquid)', 'var(--testnet-alt)'], + 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], + testnet: ['var(--testnet)', 'var(--testnet-alt)'], + testnet4: ['var(--testnet)', 'var(--testnet-alt)'], + signet: ['var(--signet)', 'var(--signet-alt)'], }; constructor( @@ -78,22 +82,38 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { ) { } - enabledMiningInfoIfNeeded(url) { - const urlParts = url.split('/'); - const onDashboard = ['','testnet','signet','mining','acceleration'].includes(urlParts[urlParts.length - 1]); - if (onDashboard) { // Only update showMiningInfo if we are on the main, mining or acceleration dashboards - this.stateService.showMiningInfo$.next(url.includes('/mining') || url.includes('/acceleration')); - } - } - ngOnInit() { this.dynamicBlocksAmount = Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT); - if (['', 'testnet', 'signet'].includes(this.stateService.network)) { - this.enabledMiningInfoIfNeeded(this.location.path()); - this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url)); - this.showMiningInfo$ = this.stateService.showMiningInfo$; - } + this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees'; + this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$ + .pipe( + filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode), + tap(() => { + this.blockTransformation = this.timeLtr ? { + transform: 'scaleX(-1) rotateX(90deg)', + transition: 'transform 0.375s' + } : { + transform: 'rotateX(90deg)', + transition: 'transform 0.375s' + }; + }), + delay(375), + tap((mode) => { + this.blockDisplayMode = mode; + this.blockTransformation = this.timeLtr ? { + transform: 'scaleX(-1)', + transition: 'transform 0.375s' + } : { + transition: 'transform 0.375s' + }; + this.cd.markForCheck(); + }), + delay(375), + ) + .subscribe(() => { + this.blockTransformation = {}; + }); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; @@ -204,6 +224,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); + this.blockDisplayModeSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); clearInterval(this.interval); } @@ -329,8 +350,8 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { return { left: addLeft + this.blockOffset * index + 'px', background: `repeating-linear-gradient( - #2d3348, - #2d3348 ${greenBackgroundHeight}%, + var(--secondary), + var(--secondary) ${greenBackgroundHeight}%, ${this.gradientColors[this.network][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[this.network][1]} 100% )`, @@ -341,7 +362,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { convertStyleForLoadingBlock(style) { return { ...style, - background: "#2d3348", + background: "var(--secondary)", }; } @@ -350,7 +371,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { return { left: addLeft + (this.blockOffset * index) + 'px', - background: "#2d3348", + background: "var(--secondary)", }; } @@ -366,7 +387,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { return { left: addLeft + this.blockOffset * this.emptyBlocks.indexOf(block) + 'px', - background: "#2d3348", + background: "var(--secondary)", }; } diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 5f625e4b3..af3bf52b1 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -10,6 +10,7 @@
+
diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index eacd16118..32225598a 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -14,8 +14,7 @@ } .blockchain-wrapper { - height: 250px; - + height: 260px; -webkit-user-select: none; /* Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* IE10+/Edge */ @@ -30,7 +29,7 @@ } .black-background { - background-color: #11131f; + background-color: var(--active-bg); z-index: 100; position: relative; } @@ -54,10 +53,10 @@ } .time-toggle { - color: white; + color: var(--fg); font-size: 0.8rem; position: absolute; - bottom: -1.8em; + bottom: -2.2em; left: 1px; transform: translateX(-50%); background: none; @@ -67,9 +66,24 @@ padding: 0; } +.block-display-toggle { + color: var(--fg); + font-size: 0.8rem; + position: absolute; + bottom: 16.1em; + left: 1px; + transform: translateX(-50%) rotate(90deg); + background: none; + border: none; + outline: none; + margin: 0; + padding: 0; +} + .blockchain-wrapper.ltr-transition .blocks-wrapper, .blockchain-wrapper.ltr-transition .position-container, -.blockchain-wrapper.ltr-transition .time-toggle { +.blockchain-wrapper.ltr-transition .time-toggle, +.blockchain-wrapper.ltr-transition .block-display-toggle { transition: transform 1s; } @@ -81,6 +95,10 @@ .time-toggle { transform: translateX(-50%) scaleX(-1); } + + .block-display-toggle { + transform: translateX(-50%) scaleX(-1) rotate(90deg); + } } :host-context(.ltr-layout) { diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index 2293b9479..d70e788a2 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; import { firstValueFrom, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { StorageService } from '../../services/storage.service'; @Component({ selector: 'app-blockchain', @@ -26,15 +27,18 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { connectionStateSubscription: Subscription; loadingTip: boolean = true; connected: boolean = true; + blockDisplayMode: 'size' | 'fees'; dividerOffset: number | null = null; mempoolOffset: number | null = null; positionStyle = { transform: "translateX(1280px)", }; + blockDisplayToggleStyle = {}; constructor( public stateService: StateService, + public StorageService: StorageService, private cd: ChangeDetectorRef, ) {} @@ -51,6 +55,7 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { firstValueFrom(this.stateService.chainTip$).then(() => { this.loadingTip = false; }); + this.blockDisplayMode = this.StorageService.getValue('block-display-mode-preference') as 'size' | 'fees' || 'fees'; } ngOnDestroy(): void { @@ -84,6 +89,13 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { }, 0); } + toggleBlockDisplayMode(): void { + if (this.blockDisplayMode === 'size') this.blockDisplayMode = 'fees'; + else this.blockDisplayMode = 'size'; + this.StorageService.setValue('block-display-mode-preference', this.blockDisplayMode); + this.stateService.blockDisplayMode$.next(this.blockDisplayMode); + } + onMempoolWidthChange(width): void { if (this.flipping) { return; diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.scss b/frontend/src/app/components/blocks-list/blocks-list.component.scss index 3d3169a69..2315844ae 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.scss +++ b/frontend/src/app/components/blocks-list/blocks-list.component.scss @@ -46,7 +46,7 @@ tr, td, th { } .progress { - background-color: #2d3348; + background-color: var(--secondary); } .pool { @@ -266,7 +266,7 @@ tr, td, th { .tooltip-custom .tooltiptext { visibility: hidden; - color: #fff; + color: var(--fg); text-align: center; padding: 5px 0; border-radius: 6px; diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.ts b/frontend/src/app/components/blocks-list/blocks-list.component.ts index 29b23e608..5270ee7be 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -73,27 +73,29 @@ export class BlocksList implements OnInit { this.seoService.setDescription($localize`:@@meta.description.bitcoin.blocks:See the most recent Bitcoin${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block reward, block size, and more.`); } - this.blocksCountInitializedSubscription = combineLatest([this.blocksCountInitialized$, this.route.queryParams]).pipe( + this.blocksCountInitializedSubscription = combineLatest([this.blocksCountInitialized$, this.route.params]).pipe( filter(([blocksCountInitialized, _]) => blocksCountInitialized), tap(([_, params]) => { this.page = +params['page'] || 1; this.page === 1 ? this.fromHeightSubject.next(undefined) : this.fromHeightSubject.next((this.blocksCount - 1) - (this.page - 1) * 15); - this.cd.markForCheck(); }) ).subscribe(); + const prevKey = this.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'; + const nextKey = this.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'; + this.keyNavigationSubscription = this.stateService.keyNavigation$ .pipe( + filter((event) => event.key === prevKey || event.key === nextKey), tap((event) => { - this.isLoading = true; - const prevKey = this.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'; - const nextKey = this.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'; if (event.key === prevKey && this.page > 1) { this.page--; + this.isLoading = true; this.cd.markForCheck(); } if (event.key === nextKey && this.page * 15 < this.blocksCount) { this.page++; + this.isLoading = true; this.cd.markForCheck(); } }), @@ -118,6 +120,7 @@ export class BlocksList implements OnInit { if (this.blocksCount === undefined) { this.blocksCount = blocks[0].height + 1; this.blocksCountInitialized$.next(true); + this.blocksCountInitialized$.complete(); } this.isLoading = false; this.lastBlockHeight = Math.max(...blocks.map(o => o.height)); @@ -179,7 +182,7 @@ export class BlocksList implements OnInit { } pageChange(page: number): void { - this.router.navigate([], { queryParams: { page: page } }); + this.router.navigate(['blocks', page]); } trackByBlock(index: number, block: BlockExtended): number { diff --git a/frontend/src/app/components/clipboard/clipboard.component.html b/frontend/src/app/components/clipboard/clipboard.component.html index ec8802634..d23ccdf8c 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.html +++ b/frontend/src/app/components/clipboard/clipboard.component.html @@ -1,7 +1,7 @@ @@ -9,7 +9,7 @@ diff --git a/frontend/src/app/components/clipboard/clipboard.component.ts b/frontend/src/app/components/clipboard/clipboard.component.ts index 7fbffdca3..6e577d8b3 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.ts +++ b/frontend/src/app/components/clipboard/clipboard.component.ts @@ -13,11 +13,17 @@ export class ClipboardComponent implements AfterViewInit { @ViewChild('buttonWrapper') buttonWrapper: ElementRef; @Input() button = false; @Input() class = 'btn btn-secondary ml-1'; - @Input() size: 'small' | 'normal' = 'normal'; + @Input() size: 'small' | 'normal' | 'large' = 'normal'; @Input() text: string; @Input() leftPadding = true; copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`; + widths = { + small: '10', + normal: '13', + large: '18', + }; + clipboard: any; constructor() { } diff --git a/frontend/src/app/components/clock-face/clock-face.component.scss b/frontend/src/app/components/clock-face/clock-face.component.scss index 1ca2ce914..e2bb215d4 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.scss +++ b/frontend/src/app/components/clock-face/clock-face.component.scss @@ -14,7 +14,7 @@ height: 100%; .face { - fill: #11131f; + fill: var(--active-bg); } } @@ -29,8 +29,8 @@ } &.hour { - fill: #105fb0; - stroke: #105fb0; + fill: var(--primary); + stroke: var(--primary); stroke-width: 6px; } } diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index 34aadcd74..7423c1d8f 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -161,7 +161,7 @@ } .side.bottom { - background: #105fb0; + background: var(--primary); transform: rotateX(-90deg); margin-top: var(--half-side); } diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index 94ff3e810..4a9b19e78 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -32,11 +32,12 @@ export class ClockComponent implements OnInit { limitHeight: number; gradientColors = { - '': ['#9339f4', '#105fb0'], - liquid: ['#116761', '#183550'], - 'liquidtestnet': ['#494a4a', '#272e46'], - testnet: ['#1d486f', '#183550'], - signet: ['#6f1d5d', '#471850'], + '': ['var(--mainnet-alt)', 'var(--primary)'], + liquid: ['var(--liquid)', 'var(--testnet-alt)'], + 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], + testnet: ['var(--testnet)', 'var(--testnet-alt)'], + testnet4: ['var(--testnet)', 'var(--testnet-alt)'], + signet: ['var(--signet)', 'var(--signet-alt)'], }; constructor( @@ -99,8 +100,8 @@ export class ClockComponent implements OnInit { return { background: `repeating-linear-gradient( - #2d3348, - #2d3348 ${greenBackgroundHeight}%, + var(--secondary), + var(--secondary) ${greenBackgroundHeight}%, ${this.gradientColors[''][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[''][1]} 100% )`, diff --git a/frontend/src/app/components/clockchain/clockchain.component.ts b/frontend/src/app/components/clockchain/clockchain.component.ts index 6cb29bcd9..c17b1e0ae 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.ts +++ b/frontend/src/app/components/clockchain/clockchain.component.ts @@ -11,7 +11,7 @@ import { StateService } from '../../services/state.service'; export class ClockchainComponent implements OnInit, OnChanges, OnDestroy { @Input() width: number = 300; @Input() height: number = 60; - @Input() mode: 'mempool' | 'mined'; + @Input() mode: 'mempool' | 'mined' | 'none'; @Input() index: number = 0; mempoolBlocks: number = 3; diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html new file mode 100644 index 000000000..bf72aab69 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -0,0 +1,286 @@ + +
+
+ @for (widget of widgets; track widget.component) { + @switch (widget.component) { + @case ('fees') { +
+
Transaction Fees
+
+
+ +
+
+
+ } + @case ('difficulty') { +
+ +
+ } + @case ('goggles') { +
+
+ +
+
+ } + @case ('incoming') { +
+
+
+ +
Incoming Transactions
+
+ +
+
+
+
+ +
+
+
Minimum fee
+
Purging
+

+ < +

+
+
+
Unconfirmed
+

+ {{ mempoolInfoData.value.memPoolInfo.size | number }} TXs +

+
+
+
Memory Usage
+
+
+
 
+
/
+
+
+
+
+
+ } + @case ('replacements') { +
+
+
+ +
Recent Replacements
+   + +
+ + + + + + + + + + + + + + + +
TXIDPrevious feeNew feeStatus
+ + + + + Mined + Full RBF + RBF +
+
+
+
+ +
+ + + + + + + + + + + + + + +
HeightMinedTXsSize
{{ block.height }}{{ block.tx_count | number }} +
+
 
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('transactions') { +
+
+
+
Recent Transactions
+ + + + + + + + + + + + + + + +
TXIDAmount{{ currency }}Fee
+ + + + Confidential
+
 
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('balance') { +
+
Treasury
+ +
+ } + @case ('address') { + + } + @case ('addressTransactions') { + + } + @case ('twitter') { +
+
+
+ +
X Timeline
+   + +
+ @defer { + + } +
+
+
+ } + } + } +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss new file mode 100644 index 000000000..4a9ffe94a --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss @@ -0,0 +1,490 @@ +.dashboard-container { + text-align: center; + margin-top: 0.5rem; + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: var(--bg); + height: 100%; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; +} + +.info-block { + float: left; + width: 350px; + line-height: 25px; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.bg-warning { + background-color: #b58800 !important; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 18px; +} + +.graph-card { + height: 100%; + @media (min-width: 768px) { + height: 415px; + } + @media (min-width: 992px) { + height: 510px; + } +} + +.mempool-info-data { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + &.lbtc-pegs-stats { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + margin: 0px auto 20px; + display: inline-block; + @media (min-width: 485px) { + margin: 0px auto 10px; + } + @media (min-width: 768px) { + margin: 0px auto 0px; + } + &:last-child { + margin: 0px auto 0px; + } + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + .bitcoin-color { + color: var(--orange); + } + } + .progress { + width: 90%; + @media (min-width: 768px) { + width: 100%; + } + } + } + .bar { + width: 93%; + margin: 0px 5px 20px; + @media (min-width: 485px) { + max-width: 200px; + margin: 0px auto 0px; + } + } + .skeleton-loader { + width: 100%; + max-width: 100px; + display: block; + margin: 18px auto 0; + } + .skeleton-loader-big { + max-width: 180px; + } +} + +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-fees { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} + +.lastest-blocks-table { + width: 100%; + text-align: left; + tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.7rem !important; + } + .table-cell-height { + width: 15%; + } + .table-cell-mined { + width: 35%; + text-align: left; + } + .table-cell-transaction-count { + display: none; + text-align: right; + width: 20%; + display: table-cell; + } + .table-cell-size { + display: none; + text-align: center; + width: 30%; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } +} + +.lastest-replacements-table { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-txid { + width: 25%; + text-align: start; + } + .table-cell-old-fee { + width: 25%; + text-align: end; + + @media(max-width: 1080px) { + display: none; + } + } + .table-cell-new-fee { + width: 20%; + text-align: end; + } + .table-cell-badges { + width: 23%; + padding-right: 0; + padding-left: 5px; + text-align: end; + + .badge { + margin-left: 5px; + } + } +} + +.mempool-graph { + height: 255px; + @media (min-width: 768px) { + height: 285px; + } + @media (min-width: 992px) { + height: 370px; + } +} +.loadingGraphs{ + height: 250px; + display: grid; + place-items: center; +} + +.inc-tx-progress-bar { + max-width: 250px; + .progress-bar { + padding: 4px; + } +} + +.terms-of-service { + margin-top: 1rem; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + &.liquid { + height: 124.5px; + } + } + .less-padding { + padding: 20px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.assetIcon { + width: 40px; + height: 40px; +} + +.asset-title { + text-align: left; + vertical-align: middle; +} + +.asset-icon { + width: 65px; + height: 65px; + vertical-align: middle; +} + +.circulating-amount { + text-align: right; + width: 100%; + vertical-align: middle; +} + +.clear-link { + color: white; +} + +.pool-name { + display: inline-block; + vertical-align: text-top; + padding-left: 10px; +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + margin-bottom: 10px; + text-decoration: none; + color: inherit; +} + +.mempool-block-wrapper { + max-height: 410px; + max-width: 410px; + margin: auto; + + @media (min-width: 768px) { + max-height: 344px; + max-width: 344px; + } + @media (min-width: 992px) { + max-height: 410px; + max-width: 410px; + } +} + +.goggle-badge { + margin: 6px 5px 8px; + background: none; + border: solid 2px var(--primary); + cursor: pointer; + + &.active { + background: var(--primary); + } +} + +.btn-xs { + padding: 0.35rem 0.5rem; + font-size: 12px; +} + +.quick-filter { + margin-top: 5px; + margin-bottom: 6px; +} + +.card-liquid { + background-color: var(--bg); + height: 418px; + @media (min-width: 992px) { + height: 512px; + } + &.smaller { + height: 408px; + } +} + +.card-title-liquid { + padding-top: 20px; + margin-left: 10px; +} + +.in-progress-message { + position: relative; + color: #ffffff91; + margin-top: 20px; + text-align: center; + padding-bottom: 3px; + font-weight: 500; +} + +.stats-card { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts new file mode 100644 index 000000000..fbaf7be74 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -0,0 +1,378 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { combineLatest, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, filter, map, scan, share, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats, TransactionStripped } from '../../interfaces/node-api.interface'; +import { MempoolInfo, ReplacementInfo } from '../../interfaces/websocket.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; +import { SeoService } from '../../services/seo.service'; +import { ActiveFilter, FilterMode, GradientMode, toFlags } from '../../shared/filters.utils'; +import { detectWebGL } from '../../shared/graphs.utils'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; + +interface MempoolBlocksData { + blocks: number; + size: number; +} + +interface MempoolInfoData { + memPoolInfo: MempoolInfo; + vBytesPerSecond: number; + progressWidth: string; + progressColor: string; +} + +interface MempoolStatsData { + mempool: OptimizedMempoolStats[]; + weightPerSecond: any; +} + +@Component({ + selector: 'app-custom-dashboard', + templateUrl: './custom-dashboard.component.html', + styleUrls: ['./custom-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewInit { + network$: Observable; + mempoolBlocksData$: Observable; + mempoolInfoData$: Observable; + mempoolLoadingStatus$: Observable; + vBytesPerSecondLimit = 1667; + transactions$: Observable; + blocks$: Observable; + replacements$: Observable; + latestBlockHeight: number; + mempoolTransactionsWeightPerSecondData: any; + mempoolStats$: Observable; + transactionsWeightPerSecondOptions: any; + isLoadingWebSocket$: Observable; + isLoad: boolean = true; + filterSubscription: Subscription; + mempoolInfoSubscription: Subscription; + currencySubscription: Subscription; + currency: string; + incomingGraphHeight: number = 300; + graphHeight: number = 300; + webGlEnabled = true; + isMobile: boolean = window.innerWidth <= 767.98; + + widgets; + + addressSubscription: Subscription; + blockTxSubscription: Subscription; + addressSummary$: Observable; + address: Address; + + goggleResolution = 82; + goggleCycle: { index: number, name: string, mode: FilterMode, filters: string[], gradient: GradientMode }[] = [ + { index: 0, name: $localize`:@@dfc3c34e182ea73c5d784ff7c8135f087992dac1:All`, mode: 'and', filters: [], gradient: 'age' }, + { index: 1, name: $localize`Consolidation`, mode: 'and', filters: ['consolidation'], gradient: 'fee' }, + { index: 2, name: $localize`Coinjoin`, mode: 'and', filters: ['coinjoin'], gradient: 'fee' }, + { index: 3, name: $localize`Data`, mode: 'or', filters: ['inscription', 'fake_pubkey', 'op_return'], gradient: 'fee' }, + ]; + goggleFlags = 0n; + goggleMode: FilterMode = 'and'; + gradientMode: GradientMode = 'age'; + goggleIndex = 0; + + private destroy$ = new Subject(); + + constructor( + public stateService: StateService, + private apiService: ApiService, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private seoService: SeoService, + private cd: ChangeDetectorRef, + @Inject(PLATFORM_ID) private platformId: Object, + ) { + this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); + this.widgets = this.stateService.env.customize?.dashboard.widgets || []; + } + + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } + + ngOnDestroy(): void { + this.filterSubscription.unsubscribe(); + this.mempoolInfoSubscription.unsubscribe(); + this.currencySubscription.unsubscribe(); + this.websocketService.stopTrackRbfSummary(); + if (this.addressSubscription) { + this.addressSubscription.unsubscribe(); + this.websocketService.stopTrackingAddress(); + this.address = null; + } + this.destroy$.next(1); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.onResize(); + this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.seoService.resetTitle(); + this.seoService.resetDescription(); + this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']); + this.websocketService.startTrackRbfSummary(); + this.network$ = merge(of(''), this.stateService.networkChanged$); + this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ + .pipe( + map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) + ); + + this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => { + const activeFilters = active.filters.sort().join(','); + for (const goggle of this.goggleCycle) { + if (goggle.mode === active.mode) { + const goggleFilters = goggle.filters.sort().join(','); + if (goggleFilters === activeFilters) { + this.goggleIndex = goggle.index; + this.goggleFlags = toFlags(goggle.filters); + this.goggleMode = goggle.mode; + this.gradientMode = active.gradient; + return; + } + } + } + this.goggleCycle.push({ + index: this.goggleCycle.length, + name: 'Custom', + mode: active.mode, + filters: active.filters, + gradient: active.gradient, + }); + this.goggleIndex = this.goggleCycle.length - 1; + this.goggleFlags = toFlags(active.filters); + this.goggleMode = active.mode; + }); + + this.mempoolInfoData$ = combineLatest([ + this.stateService.mempoolInfo$, + this.stateService.vbytesPerSecond$ + ]).pipe( + map(([mempoolInfo, vbytesPerSecond]) => { + const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); + + let progressColor = 'bg-success'; + if (vbytesPerSecond > 1667) { + progressColor = 'bg-warning'; + } + if (vbytesPerSecond > 3000) { + progressColor = 'bg-danger'; + } + + const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); + let mempoolSizeProgress = 'bg-danger'; + if (mempoolSizePercentage <= 50) { + mempoolSizeProgress = 'bg-success'; + } else if (mempoolSizePercentage <= 75) { + mempoolSizeProgress = 'bg-warning'; + } + + return { + memPoolInfo: mempoolInfo, + vBytesPerSecond: vbytesPerSecond, + progressWidth: percent + '%', + progressColor: progressColor, + mempoolSizeProgress: mempoolSizeProgress, + }; + }) + ); + + this.mempoolInfoSubscription = this.mempoolInfoData$.subscribe(); + + this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ + .pipe( + map((mempoolBlocks) => { + const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0); + const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0); + + return { + size: size, + blocks: Math.ceil(vsize / this.stateService.blockVSize) + }; + }) + ); + + this.transactions$ = this.stateService.transactions$; + + this.blocks$ = this.stateService.blocks$ + .pipe( + tap((blocks) => { + this.latestBlockHeight = blocks[0].height; + }), + switchMap((blocks) => { + if (this.stateService.env.MINING_DASHBOARD === true) { + for (const block of blocks) { + // @ts-ignore: Need to add an extra field for the template + block.extras.pool.logo = `/resources/mining-pools/` + + block.extras.pool.slug + '.svg'; + } + } + return of(blocks.slice(0, 6)); + }) + ); + + this.replacements$ = this.stateService.rbfLatestSummary$; + + this.mempoolStats$ = this.stateService.connectionState$ + .pipe( + filter((state) => state === 2), + switchMap(() => this.apiService.list2HStatistics$().pipe( + catchError((e) => { + return of(null); + }) + )), + switchMap((mempoolStats) => { + return merge( + this.stateService.live2Chart$ + .pipe( + scan((acc, stats) => { + const now = Date.now() / 1000; + const start = now - (2 * 60 * 60); + acc.unshift(stats); + acc = acc.filter(p => p.added >= start); + return acc; + }, (mempoolStats || [])) + ), + of(mempoolStats) + ); + }), + map((mempoolStats) => { + if (mempoolStats) { + return { + mempool: mempoolStats, + weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), + }; + } else { + return null; + } + }), + shareReplay(1), + ); + + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + + this.startAddressSubscription(); + } + + handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + return { + labels: labels, + series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])], + }; + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; + } + + getArrayFromNumber(num: number): number[] { + return Array.from({ length: num }, (_, i) => i + 1); + } + + setFilter(index): void { + const selected = this.goggleCycle[index]; + this.stateService.activeGoggles$.next(selected); + } + + startAddressSubscription(): void { + if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.address)) { + let addressString = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.address).props.address; + addressString = (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(addressString)) ? addressString.toLowerCase() : addressString; + + this.addressSubscription = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getPubKeyAddress$(addressString) + : this.electrsApiService.getAddress$(addressString) + ).pipe( + catchError((err) => { + console.log(err); + return of(null); + }), + filter((address) => !!address), + ).subscribe((address: Address) => { + this.websocketService.startTrackAddress(address.address); + this.address = address; + this.cd.markForCheck(); + }); + + this.addressSummary$ = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getScriptHashSummary$((addressString.length === 66 ? '21' : '41') + addressString + 'ac') + : this.electrsApiService.getAddressSummary$(addressString)).pipe( + catchError(e => { + return of(null); + }), + switchMap(initial => this.stateService.blockTransactions$.pipe( + startWith(null), + scan((summary, tx) => { + if (tx && !summary.some(t => t.txid === tx.txid)) { + let value = 0; + let funded = 0; + let fundedCount = 0; + let spent = 0; + let spentCount = 0; + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === addressString) { + value += vout.value; + funded += vout.value; + fundedCount++; + } + } + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === addressString) { + value -= vin.prevout?.value; + spent += vin.prevout?.value; + spentCount++; + } + } + if (this.address && this.address.address === addressString) { + this.address.chain_stats.tx_count++; + this.address.chain_stats.funded_txo_sum += funded; + this.address.chain_stats.funded_txo_count += fundedCount; + this.address.chain_stats.spent_txo_sum += spent; + this.address.chain_stats.spent_txo_count += spentCount; + } + summary.unshift({ + txid: tx.txid, + time: tx.status?.block_time, + height: tx.status?.block_height, + value + }); + } + return summary; + }, initial) + )), + share(), + ); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (window.innerWidth >= 992) { + this.incomingGraphHeight = 300; + this.goggleResolution = 82; + this.graphHeight = 400; + } else if (window.innerWidth >= 768) { + this.incomingGraphHeight = 215; + this.goggleResolution = 80; + this.graphHeight = 310; + } else { + this.incomingGraphHeight = 180; + this.goggleResolution = 86; + this.graphHeight = 310; + } + this.isMobile = window.innerWidth <= 767.98; + } +} diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html index 569fcb188..bf480289b 100644 --- a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html @@ -14,7 +14,7 @@ {{ diffChange.height }} - + {{ diffChange.difficultyShorten }} diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts index 7db1367ea..438a50f74 100644 --- a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts @@ -15,7 +15,7 @@ import { StateService } from '../../services/state.service'; position: absolute; top: 50%; left: calc(50% - 15px); - z-index: 100; + z-index: 99; } `], }) diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html index 0865708af..4d135dfbe 100644 --- a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html @@ -40,7 +40,7 @@
Current Period
{{ epochData.progress | number: '1.2-2' }} %
-
 
+
 
diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss index 69e2892ee..bd396928f 100644 --- a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss @@ -4,7 +4,7 @@ justify-content: space-around; height: 76px; .shared-block { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; } .item { @@ -79,12 +79,12 @@ } .card { - background-color: #1d1f31; + background-color: var(--bg); height: 100%; } .card-title { - color: #4a68b9; + color: var(--title-fg); font-size: 1rem; overflow: hidden; text-overflow: ellipsis; @@ -94,7 +94,7 @@ .progress { display: inline-flex; width: 100%; - background-color: #2d3348; + background-color: var(--secondary); height: 1.1rem; max-width: 180px; } @@ -119,7 +119,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts index c650e2c02..90b41d749 100644 --- a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts @@ -49,24 +49,24 @@ export class DifficultyMiningComponent implements OnInit { .pipe( map(([blocks, da]) => { const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); - let colorAdjustments = '#ffffff66'; + let colorAdjustments = 'var(--transparent-fg)'; if (da.difficultyChange > 0) { - colorAdjustments = '#3bcc49'; + colorAdjustments = 'var(--green)'; } if (da.difficultyChange < 0) { - colorAdjustments = '#dc3545'; + colorAdjustments = 'var(--red)'; } - let colorPreviousAdjustments = '#dc3545'; + let colorPreviousAdjustments = 'var(--red)'; if (da.previousRetarget) { if (da.previousRetarget >= 0) { - colorPreviousAdjustments = '#3bcc49'; + colorPreviousAdjustments = 'var(--green)'; } if (da.previousRetarget === 0) { - colorPreviousAdjustments = '#ffffff66'; + colorPreviousAdjustments = 'var(--transparent-fg)'; } } else { - colorPreviousAdjustments = '#ffffff66'; + colorPreviousAdjustments = 'var(--transparent-fg)'; } this.blocksUntilHalving = 210000 - (maxHeight % 210000); diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss index 5b4a8a02f..e4fd989af 100644 --- a/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss +++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss @@ -1,9 +1,9 @@ .difficulty-tooltip { position: fixed; - background: rgba(#11131f, 0.95); + background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0,0,0,0.5); - color: #b1b1b1; + color: var(--tooltip-grey); padding: 10px 15px; text-align: left; pointer-events: none; diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index ff31d4f57..e9bf36515 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -5,7 +5,7 @@ @@ -15,8 +15,8 @@ - - + + diff --git a/frontend/src/app/components/difficulty/difficulty.component.scss b/frontend/src/app/components/difficulty/difficulty.component.scss index 3b591dc2d..8de7fae2c 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.scss +++ b/frontend/src/app/components/difficulty/difficulty.component.scss @@ -10,7 +10,7 @@ justify-content: space-around; height: 50.5px; .shared-block { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; } .item { @@ -91,19 +91,19 @@ } .card { - background-color: #1d1f31; + background-color: var(--bg); height: 100%; } .card-title { - color: #4a68b9; + color: var(--title-fg); font-size: 1rem; } .progress { display: inline-flex; width: 100%; - background-color: #2d3348; + background-color: var(--secondary); height: 1.1rem; max-width: 180px; } @@ -128,7 +128,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -177,19 +178,19 @@ .epoch-blocks { display: block; width: 100%; - background: #2d3348; + background: var(--secondary); .rect { - fill: #2d3348; + fill: var(--secondary); &.behind { - fill: #D81B60; + fill: var(--red); } &.mined { fill: url(#diff-gradient); } &.ahead { - fill: #1a9436; + fill: var(--success); } &.hover { @@ -208,10 +209,10 @@ } .blocks-ahead { - color: #3bcc49; + color: var(--green); } .blocks-behind { - color: #D81B60; + color: var(--red); } .halving-progress { @@ -223,12 +224,12 @@ height: 100%; } .background { - background: linear-gradient(to right, #105fb0, #9339f4); + background: linear-gradient(to right, var(--primary), var(--mainnet-alt)); left: 0; right: 0; } .remaining { - background: #2d3348; + background: var(--secondary); right: 0; } .label { @@ -250,5 +251,5 @@ } .inactive { - color: #ffffff66; + color: var(--transparent-fg); } \ No newline at end of file diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts index a58250653..579b49fc3 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.ts +++ b/frontend/src/app/components/difficulty/difficulty.component.ts @@ -82,24 +82,24 @@ export class DifficultyComponent implements OnInit { .pipe( map(([blocks, da]) => { const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); - let colorAdjustments = '#ffffff66'; + let colorAdjustments = 'var(--transparent-fg)'; if (da.difficultyChange > 0) { - colorAdjustments = '#3bcc49'; + colorAdjustments = 'var(--green)'; } if (da.difficultyChange < 0) { - colorAdjustments = '#dc3545'; + colorAdjustments = 'var(--red)'; } - let colorPreviousAdjustments = '#dc3545'; + let colorPreviousAdjustments = 'var(--red)'; if (da.previousRetarget) { if (da.previousRetarget >= 0) { - colorPreviousAdjustments = '#3bcc49'; + colorPreviousAdjustments = 'var(--green)'; } if (da.previousRetarget === 0) { - colorPreviousAdjustments = '#ffffff66'; + colorPreviousAdjustments = 'var(--transparent-fg)'; } } else { - colorPreviousAdjustments = '#ffffff66'; + colorPreviousAdjustments = 'var(--transparent-fg)'; } const blocksUntilHalving = 210000 - (maxHeight % 210000); diff --git a/frontend/src/app/components/faucet/faucet.component.html b/frontend/src/app/components/faucet/faucet.component.html new file mode 100644 index 000000000..0f0307e54 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.html @@ -0,0 +1,97 @@ +
+ +
+

Testnet4 Faucet

+
+ +
+ + @if (txid) { +
+ + Sent! + {{ txid }} +
+ } + @if (loading) { +

Loading faucet...

+
+ } @else if (!user) { + +
+
+ To use the faucet, please  + login +  or +
+ +
+ } + @else if (error === 'not_available') { + +
+
+ To use the faucet, please +
+ +
+ } + @else if (error === 'account_limited') { +
+
+ Your Twitter account does not allow you to access the faucet +
+
+ } + @else if (error) { + + + } + + @if (!loading) { + +
+
+
+
+
+ Amount (sats) +
+ +
+ + + +
+
+
+
Amount is required
+
Minimum is {{ amount?.errors?.['min'].min | number }} tSats
+
Maximum is {{ amount?.errors?.['max'].max | number }} tSats
+
+
+
+ Address +
+ + +
+
+
Address is required
+
Must be a valid testnet4 address
+
You cannot use this address
+
+
+
+
+ + } + + + @if (status?.address) { +
If you no longer need your testnet4 coins, please consider sending them back to replenish the faucet.
+ } + +
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/faucet/faucet.component.scss b/frontend/src/app/components/faucet/faucet.component.scss new file mode 100644 index 000000000..c3d3a5b33 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.scss @@ -0,0 +1,52 @@ +.formGroup { + width: 100%; +} + +.input-group { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-end; + row-gap: 0.5rem; + gap: 0.5rem; + + .form-control { + min-width: 160px; + flex-grow: 100; + } + + .button-group { + display: flex; + align-items: stretch; + } + + .submit-button, .button-group, .button-group .btn { + flex-grow: 1; + } + .submit-button:disabled { + cursor: not-allowed; + } + + #satoshis::after { + content: 'sats'; + position: absolute; + right: 0.5em; + top: 0; + bottom: 0; + } +} + +.faucet-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 800px; + margin: auto; +} + +.invalid { + border-width: 1px; + border-color: var(--red); +} diff --git a/frontend/src/app/components/faucet/faucet.component.ts b/frontend/src/app/components/faucet/faucet.component.ts new file mode 100644 index 000000000..566a3b970 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -0,0 +1,184 @@ +import { Component, OnDestroy, OnInit, ChangeDetectorRef } from "@angular/core"; +import { FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms"; +import { Subscription } from "rxjs"; +import { StorageService } from "../../services/storage.service"; +import { ServicesApiServices } from "../../services/services-api.service"; +import { getRegex } from "../../shared/regex.utils"; +import { StateService } from "../../services/state.service"; +import { WebsocketService } from "../../services/websocket.service"; +import { AudioService } from "../../services/audio.service"; +import { HttpErrorResponse } from "@angular/common/http"; + +@Component({ + selector: 'app-faucet', + templateUrl: './faucet.component.html', + styleUrls: ['./faucet.component.scss'] +}) +export class FaucetComponent implements OnInit, OnDestroy { + loading = true; + error: string = ''; + user: any = undefined; + txid: string = ''; + + faucetStatusSubscription: Subscription; + status: { + min: number; // minimum amount to request at once (in sats) + max: number; // maximum amount to request at once + address?: string; // faucet address + code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon'; + } | null = null; + faucetForm: FormGroup; + + mempoolPositionSubscription: Subscription; + confirmationSubscription: Subscription; + + constructor( + private cd: ChangeDetectorRef, + private storageService: StorageService, + private servicesApiService: ServicesApiServices, + private formBuilder: FormBuilder, + private stateService: StateService, + private websocketService: WebsocketService, + private audioService: AudioService + ) { + this.initForm(5000, 500_000, null); + } + + ngOnDestroy() { + this.stateService.markBlock$.next({}); + this.websocketService.stopTrackingTransaction(); + if (this.mempoolPositionSubscription) { + this.mempoolPositionSubscription.unsubscribe(); + } + if (this.confirmationSubscription) { + this.confirmationSubscription.unsubscribe(); + } + } + + ngOnInit() { + this.user = this.storageService.getAuth()?.user ?? null; + if (!this.user) { + this.loading = false; + return; + } + + // Setup form + this.updateFaucetStatus(); + + // Track transaction + this.websocketService.want(['blocks', 'mempool-blocks']); + this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { + if (txPosition && txPosition.txid === this.txid) { + this.stateService.markBlock$.next({ + txid: txPosition.txid, + mempoolPosition: txPosition.position, + }); + } + }); + + this.confirmationSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => { + if (txConfirmed && txConfirmed === this.txid) { + this.stateService.markBlock$.next({ blockHeight: block.height }); + } + }); + } + + updateFaucetStatus(): void { + this.servicesApiService.getFaucetStatus$().subscribe({ + next: (status) => { + if (!status) { + this.error = 'internal_server_error'; + return; + } + this.status = status; + if (this.status.code !== 'ok') { + this.error = this.status.code; + this.updateForm(this.status.min ?? 5000, this.status.max ?? 500_000, this.status.address); + return; + } + // update the form with the proper validation parameters + this.updateForm(this.status.min, this.status.max, this.status.address); + }, + error: (response) => { + this.loading = false; + this.error = response.error; + this.cd.markForCheck(); + } + }); + } + + requestCoins(): void { + if (this.isDisabled()) { + return; + } + this.error = null; + this.txid = ''; + this.stateService.markBlock$.next({}); + this.servicesApiService.requestTestnet4Coins$(this.faucetForm.get('address')?.value, parseInt(this.faucetForm.get('satoshis')?.value)) + .subscribe({ + next: ((response) => { + this.txid = response.txid; + this.websocketService.startTrackTransaction(this.txid); + this.audioService.playSound('cha-ching'); + this.updateFaucetStatus(); + this.cd.markForCheck(); + }), + error: (response: HttpErrorResponse) => { + this.error = response.error; + }, + }); + } + + isDisabled(): boolean { + return !(this.user && this.status?.code === 'ok' && !this.error); + } + + getNotFaucetAddressValidator(faucetAddress: string): ValidatorFn { + return faucetAddress ? (control: AbstractControl): ValidationErrors | null => { + const forbidden = control.value === faucetAddress; + return forbidden ? { forbiddenAddress: { value: control.value } } : null; + }: () => null; + } + + initForm(min: number, max: number, faucetAddress: string): void { + this.faucetForm = this.formBuilder.group({ + 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4')), this.getNotFaucetAddressValidator(faucetAddress)]], + 'satoshis': [min, [Validators.required, Validators.min(min), Validators.max(max)]] + }); + + this.loading = false; + this.cd.markForCheck(); + } + + updateForm(min, max, faucetAddress: string): void { + if (!this.faucetForm) { + this.initForm(min, max, faucetAddress); + } else { + this.faucetForm.get('address').setValidators([Validators.required, Validators.pattern(getRegex('address', 'testnet4')), this.getNotFaucetAddressValidator(faucetAddress)]); + this.faucetForm.get('satoshis').setValidators([Validators.required, Validators.min(min), Validators.max(max)]); + this.faucetForm.get('satoshis').setValue(Math.max(min, this.faucetForm.get('satoshis').value)); + this.faucetForm.get('satoshis').updateValueAndValidity(); + this.faucetForm.get('satoshis').markAsDirty(); + } + } + + setAmount(value: number): void { + if (this.faucetForm) { + this.faucetForm.get('satoshis').setValue(value); + this.faucetForm.get('satoshis').updateValueAndValidity(); + this.faucetForm.get('satoshis').markAsDirty(); + } + } + + get amount() { return this.faucetForm.get('satoshis')!; } + get invalidAmount() { + const amount = this.faucetForm.get('satoshis')!; + return amount?.invalid && (amount.dirty || amount.touched) + } + + get address() { return this.faucetForm.get('address')!; } + get invalidAddress() { + const address = this.faucetForm.get('address')!; + return address?.invalid && (address.dirty || address.touched) + } +} diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index ca5b3f452..c26aae31a 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -128,7 +128,7 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr splitLine: { lineStyle: { type: 'dotted', - color: '#ffffff66', + color: 'var(--transparent-fg)', opacity: 0.25, } }, diff --git a/frontend/src/app/components/fees-box/fees-box.component.scss b/frontend/src/app/components/fees-box/fees-box.component.scss index ba68e2086..c5843f58b 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.scss +++ b/frontend/src/app/components/fees-box/fees-box.component.scss @@ -1,5 +1,5 @@ .card-title { - color: #4a68b9; + color: var(--title-fg); font-size: 10px; margin-bottom: 4px; font-size: 1rem; @@ -36,7 +36,7 @@ margin-bottom: 0; } .card-text span { - color: #ffffff66; + color: var(--transparent-fg); font-size: 12px; top: 0px; } @@ -79,6 +79,7 @@ display: flex; flex-direction: row; transition: background-color 1s; + color: #fff; &.priority { @media (767px < width < 992px), (width < 576px) { width: 100%; diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index 4f9772b22..78fd102ca 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.ts +++ b/frontend/src/app/components/fees-box/fees-box.component.ts @@ -1,9 +1,10 @@ -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { Observable, combineLatest } from 'rxjs'; +import { Observable, combineLatest, Subscription } from 'rxjs'; import { Recommendedfees } from '../../interfaces/websocket.interface'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { feeLevels } from '../../app.constants'; import { map, startWith, tap } from 'rxjs/operators'; +import { ThemeService } from '../../services/theme.service'; @Component({ selector: 'app-fees-box', @@ -11,14 +12,18 @@ import { map, startWith, tap } from 'rxjs/operators'; styleUrls: ['./fees-box.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FeesBoxComponent implements OnInit { +export class FeesBoxComponent implements OnInit, OnDestroy { isLoading$: Observable; recommendedFees$: Observable; - gradient = 'linear-gradient(to right, #2e324e, #2e324e)'; - noPriority = '#2e324e'; + themeSubscription: Subscription; + gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))'; + noPriority = 'var(--skeleton-bg)'; + fees: Recommendedfees; constructor( - private stateService: StateService + private stateService: StateService, + private themeService: ThemeService, + private cd: ChangeDetectorRef, ) { } ngOnInit(): void { @@ -31,18 +36,32 @@ export class FeesBoxComponent implements OnInit { this.recommendedFees$ = this.stateService.recommendedFees$ .pipe( tap((fees) => { - let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => fees.minimumFee >= feeLvl); - feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; - const startColor = '#' + (mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]); - - feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => fees.fastestFee >= feeLvl); - feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; - const endColor = '#' + (mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]); - - this.gradient = `linear-gradient(to right, ${startColor}, ${endColor})`; - this.noPriority = startColor; + this.fees = fees; + this.setFeeGradient(); } ) ); + this.themeSubscription = this.themeService.themeChanged$.subscribe(() => { + this.setFeeGradient(); + }) + } + + setFeeGradient() { + let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => this.fees.minimumFee >= feeLvl); + feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; + const startColor = '#' + (this.themeService.mempoolFeeColors[feeLevelIndex - 1] || this.themeService.mempoolFeeColors[this.themeService.mempoolFeeColors.length - 1]); + + feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => this.fees.fastestFee >= feeLvl); + feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; + const endColor = '#' + (this.themeService.mempoolFeeColors[feeLevelIndex - 1] || this.themeService.mempoolFeeColors[this.themeService.mempoolFeeColors.length - 1]); + + this.gradient = `linear-gradient(to right, ${startColor}, ${endColor})`; + this.noPriority = startColor; + + this.cd.markForCheck(); + } + + ngOnDestroy(): void { + this.themeSubscription.unsubscribe(); } } diff --git a/frontend/src/app/components/footer/footer.component.scss b/frontend/src/app/components/footer/footer.component.scss index cc0146345..b21f39676 100644 --- a/frontend/src/app/components/footer/footer.component.scss +++ b/frontend/src/app/components/footer/footer.component.scss @@ -3,7 +3,7 @@ bottom: 0; width: 100%; height: 60px; - background-color: #1d1f31; + background-color: var(--bg); box-shadow: 15px 15px 15px 15px #000; z-index: 10; @@ -40,16 +40,8 @@ } } -.txPerSecond { - color: #4a9ff4; -} - .mempoolSize { - color: #4a68b9; -} - -.unconfirmedTx { - color: #f14d80; + color: var(--title-fg); } .info-block { @@ -61,7 +53,7 @@ .progress { display: inline-flex; width: 160px; - background-color: #2d3348; + background-color: var(--secondary); height: 1.1rem; } diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 94241b825..c11a0dc96 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -1,9 +1,9 @@ -