diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a29e9184..6d2fc387f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,17 +251,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mempool", "liquid"] - include: - - module: "mempool" - spec: | - cypress/e2e/mainnet/*.spec.ts - cypress/e2e/signet/*.spec.ts - cypress/e2e/testnet4/*.spec.ts - - module: "liquid" - spec: | - cypress/e2e/liquid/liquid.spec.ts - cypress/e2e/liquidtestnet/liquidtestnet.spec.ts + module: ["mempool", "liquid", "testnet4"] name: E2E tests for ${{ matrix.module }} steps: @@ -310,8 +300,10 @@ jobs: - name: Unzip assets before building (src/resources) run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video - + + # mempool - name: Chrome browser tests (${{ matrix.module }}) + if: ${{ matrix.module == 'mempool' }} uses: cypress-io/github-action@v5 with: tag: ${{ github.event_name }} @@ -322,7 +314,9 @@ jobs: wait-on-timeout: 120 record: true parallel: true - spec: ${{ matrix.spec }} + spec: | + cypress/e2e/mainnet/*.spec.ts + cypress/e2e/signet/*.spec.ts group: Tests on Chrome (${{ matrix.module }}) browser: "chrome" ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}" @@ -332,6 +326,56 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} + # liquid + - name: Chrome browser tests (${{ matrix.module }}) + if: ${{ matrix.module == 'liquid' }} + uses: cypress-io/github-action@v5 + with: + tag: ${{ github.event_name }} + working-directory: ${{ matrix.module }}/frontend + build: npm run config:defaults:${{ matrix.module }} + start: npm run start:local-staging + wait-on: "http://localhost:4200" + wait-on-timeout: 120 + record: true + parallel: true + spec: | + cypress/e2e/liquid/liquid.spec.ts + cypress/e2e/liquidtestnet/liquidtestnet.spec.ts + group: Tests on Chrome (${{ matrix.module }}) + browser: "chrome" + ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}" + env: + COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} + + # testnet + - name: Chrome browser tests (${{ matrix.module }}) + if: ${{ matrix.module == 'testnet4' }} + uses: cypress-io/github-action@v5 + with: + tag: ${{ github.event_name }} + working-directory: ${{ matrix.module }}/frontend + build: npm run config:defaults:mempool + start: npm run start:local-staging + wait-on: "http://localhost:4200" + wait-on-timeout: 120 + record: true + parallel: true + spec: | + cypress/e2e/testnet4/*.spec.ts + group: Tests on Chrome (${{ matrix.module }}) + browser: "chrome" + ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}" + env: + CYPRESS_REROUTE_TESTNET: true + COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} + validate_docker_json: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" runs-on: "ubuntu-latest" @@ -359,4 +403,4 @@ jobs: - name: Validate JSON syntax run: | cat mempool-config.json | jq - working-directory: docker/docker/backend + working-directory: docker/docker/backend \ No newline at end of file diff --git a/backend/jest.config.ts b/backend/jest.config.ts index 14f932f98..43246c518 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -7,7 +7,7 @@ const config: Config.InitialOptions = { automock: false, collectCoverage: true, collectCoverageFrom: ["./src/**/**.ts"], - coverageProvider: "babel", + coverageProvider: "v8", coverageThreshold: { global: { lines: 1 diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 7ad25dff0..c2715153b 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -155,6 +155,10 @@ "API": "https://mempool.space/api/v1/services", "ACCELERATIONS": false }, + "STRATUM": { + "ENABLED": false, + "API": "http://localhost:1234" + }, "FIAT_PRICE": { "ENABLED": true, "PAID": false, diff --git a/backend/package-lock.json b/backend/package-lock.json index e0d28bfc9..3f66fa25b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "license": "GNU Affero General Public License v3.0", "dependencies": { - "@babel/core": "^7.25.2", "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", "axios": "1.7.2", @@ -18,7 +17,7 @@ "crypto-js": "~4.2.0", "express": "~4.21.1", "maxmind": "~4.3.11", - "mysql2": "~3.11.0", + "mysql2": "~3.12.0", "redis": "^4.7.0", "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", @@ -26,8 +25,6 @@ "ws": "~8.18.0" }, "devDependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/core": "^7.25.2", "@types/compression": "^1.7.2", "@types/crypto-js": "^4.1.1", "@types/express": "^4.17.17", @@ -6000,6 +5997,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6161,16 +6173,17 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mysql2": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.0.tgz", - "integrity": "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", "long": "^5.2.1", - "lru-cache": "^8.0.0", + "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" @@ -6190,14 +6203,6 @@ "node": ">=0.10.0" } }, - "node_modules/mysql2/node_modules/lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", - "engines": { - "node": ">=16.14" - } - }, "node_modules/named-placeholders": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", @@ -12213,6 +12218,11 @@ "yallist": "^3.0.2" } }, + "lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -12327,16 +12337,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "mysql2": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.0.tgz", - "integrity": "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", "requires": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", "long": "^5.2.1", - "lru-cache": "^8.0.0", + "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" @@ -12349,11 +12359,6 @@ "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" } - }, - "lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==" } } }, diff --git a/backend/package.json b/backend/package.json index 9ac3f9199..ee5944f93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,7 +39,6 @@ "prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"" }, "dependencies": { - "@babel/core": "^7.25.2", "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", "axios": "1.7.2", @@ -47,7 +46,7 @@ "crypto-js": "~4.2.0", "express": "~4.21.1", "maxmind": "~4.3.11", - "mysql2": "~3.11.0", + "mysql2": "~3.12.0", "rust-gbt": "file:./rust-gbt", "redis": "^4.7.0", "socks-proxy-agent": "~7.0.0", @@ -55,8 +54,6 @@ "ws": "~8.18.0" }, "devDependencies": { - "@babel/code-frame": "^7.18.6", - "@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 a9f246767..0ca5654a5 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -151,5 +151,9 @@ "ENABLED": true, "PAID": false, "API_KEY": "__MEMPOOL_CURRENCY_API_KEY__" + }, + "STRATUM": { + "ENABLED": false, + "API": "http://localhost:1234" } } diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index b3cf7e2a7..e0437941f 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -159,6 +159,11 @@ describe('Mempool Backend Config', () => { PAID: false, API_KEY: '', }); + + expect(config.STRATUM).toStrictEqual({ + ENABLED: false, + API: 'http://localhost:1234', + }); }); }); diff --git a/backend/src/api/bitcoin/bitcoin-core.routes.ts b/backend/src/api/bitcoin/bitcoin-core.routes.ts index 2c3dd08f6..7e1dcea74 100644 --- a/backend/src/api/bitcoin/bitcoin-core.routes.ts +++ b/backend/src/api/bitcoin/bitcoin-core.routes.ts @@ -3,6 +3,10 @@ import logger from '../../logger'; import bitcoinClient from './bitcoin-client'; import config from '../../config'; +const BLOCKHASH_REGEX = /^[a-f0-9]{64}$/i; +const TXID_REGEX = /^[a-f0-9]{64}$/i; +const RAW_TX_REGEX = /^[a-f0-9]{2,}$/i; + /** * Define a set of routes used by the accelerator server * Those routes are not designed to be public @@ -10,7 +14,7 @@ import config from '../../config'; class BitcoinBackendRoutes { private static tag = 'BitcoinBackendRoutes'; - public initRoutes(app: Application) { + public initRoutes(app: Application): void { app .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry) .post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction) @@ -26,10 +30,10 @@ class BitcoinBackendRoutes { /** * Disable caching for bitcoin core routes - * - * @param req - * @param res - * @param next + * + * @param req + * @param res + * @param next */ private disableCache(req: Request, res: Response, next: NextFunction): void { res.setHeader('Pragma', 'no-cache'); @@ -40,16 +44,16 @@ class BitcoinBackendRoutes { /** * Exeption handler to return proper details to the accelerator server - * - * @param e - * @param fnName - * @param res + * + * @param e + * @param fnName + * @param res */ private static handleException(e: any, fnName: string, res: Response): void { if (typeof(e.code) === 'number') { - res.status(400).send(JSON.stringify(e, ['code', 'message'])); - } else { - const err = `exception in ${fnName}. ${e}. Details: ${JSON.stringify(e, ['code', 'message'])}`; + res.status(400).send(JSON.stringify(e, ['code'])); + } else { + const err = `unknown exception in ${fnName}`; logger.err(err, BitcoinBackendRoutes.tag); res.status(500).send(err); } @@ -58,13 +62,13 @@ class BitcoinBackendRoutes { private async $getMempoolEntry(req: Request, res: Response): Promise { const txid = req.query.txid; try { - if (typeof(txid) !== 'string' || txid.length !== 64) { - res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); + if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) { + res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`); return; } const mempoolEntry = await bitcoinClient.getMempoolEntry(txid); if (!mempoolEntry) { - res.status(404).send(`no mempool entry found for txid ${txid}`); + res.status(404).send(); return; } res.status(200).send(mempoolEntry); @@ -76,13 +80,13 @@ class BitcoinBackendRoutes { private async $decodeRawTransaction(req: Request, res: Response): Promise { const rawTx = req.body.rawTx; try { - if (typeof(rawTx) !== 'string') { - res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`); + if (typeof(rawTx) !== 'string' || !RAW_TX_REGEX.test(rawTx)) { + res.status(400).send(`invalid param rawTx. must be a string of hexadecimal characters`); return; } const decodedTx = await bitcoinClient.decodeRawTransaction(rawTx); if (!decodedTx) { - res.status(400).send(`unable to decode rawTx ${rawTx}`); + res.status(400).send(`unable to decode rawTx`); return; } res.status(200).send(decodedTx); @@ -95,23 +99,23 @@ class BitcoinBackendRoutes { const txid = req.query.txid; const verbose = req.query.verbose; try { - if (typeof(txid) !== 'string' || txid.length !== 64) { - res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); + if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) { + res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`); return; } if (typeof(verbose) !== 'string') { - res.status(400).send(`invalid param verbose ${verbose}. must be a string representing an integer`); + res.status(400).send(`invalid param verbose. must be a string representing an integer`); return; } const verboseNumber = parseInt(verbose, 10); if (typeof(verboseNumber) !== 'number') { - res.status(400).send(`invalid param verbose ${verbose}. must be a valid integer`); + res.status(400).send(`invalid param verbose. must be a valid integer`); return; } const decodedTx = await bitcoinClient.getRawTransaction(txid, verboseNumber); if (!decodedTx) { - res.status(400).send(`unable to get raw transaction for txid ${txid}`); + res.status(400).send(`unable to get raw transaction`); return; } res.status(200).send(decodedTx); @@ -123,13 +127,13 @@ class BitcoinBackendRoutes { private async $sendRawTransaction(req: Request, res: Response): Promise { const rawTx = req.body.rawTx; try { - if (typeof(rawTx) !== 'string') { - res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`); + if (typeof(rawTx) !== 'string' || !RAW_TX_REGEX.test(rawTx)) { + res.status(400).send(`invalid param rawTx. must be a string of hexadecimal characters`); return; } const txHex = await bitcoinClient.sendRawTransaction(rawTx); if (!txHex) { - res.status(400).send(`unable to send rawTx ${rawTx}`); + res.status(400).send(`unable to send rawTx`); return; } res.status(200).send(txHex); @@ -141,13 +145,13 @@ class BitcoinBackendRoutes { private async $testMempoolAccept(req: Request, res: Response): Promise { const rawTxs = req.body.rawTxs; try { - if (typeof(rawTxs) !== 'object') { - res.status(400).send(`invalid param rawTxs ${JSON.stringify(rawTxs)}. must be an array of string`); + if (typeof(rawTxs) !== 'object' || !Array.isArray(rawTxs) || rawTxs.some((tx) => typeof(tx) !== 'string' || !RAW_TX_REGEX.test(tx))) { + res.status(400).send(`invalid param rawTxs. must be an array of strings of hexadecimal characters`); return; } const txHex = await bitcoinClient.testMempoolAccept(rawTxs); if (typeof(txHex) !== 'object' || txHex.length === 0) { - res.status(400).send(`testmempoolaccept failed for raw txs ${JSON.stringify(rawTxs)}, got an empty result`); + res.status(400).send(`testmempoolaccept failed for raw txs, got an empty result`); return; } res.status(200).send(txHex); @@ -160,18 +164,18 @@ class BitcoinBackendRoutes { const txid = req.query.txid; const verbose = req.query.verbose; try { - if (typeof(txid) !== 'string' || txid.length !== 64) { - res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); + if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) { + res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`); return; } if (typeof(verbose) !== 'string' || (verbose !== 'true' && verbose !== 'false')) { - res.status(400).send(`invalid param verbose ${verbose}. must be a string ('true' | 'false')`); + res.status(400).send(`invalid param verbose. must be a string ('true' | 'false')`); return; } - + const ancestors = await bitcoinClient.getMempoolAncestors(txid, verbose === 'true' ? true : false); if (!ancestors) { - res.status(400).send(`unable to get mempool ancestors for txid ${txid}`); + res.status(400).send(`unable to get mempool ancestors`); return; } res.status(200).send(ancestors); @@ -184,23 +188,23 @@ class BitcoinBackendRoutes { const blockHash = req.query.hash; const verbosity = req.query.verbosity; try { - if (typeof(blockHash) !== 'string' || blockHash.length !== 64) { - res.status(400).send(`invalid param blockHash ${blockHash}. must be a string of 64 char`); + if (typeof(blockHash) !== 'string' || blockHash.length !== 64 || !BLOCKHASH_REGEX.test(blockHash)) { + res.status(400).send(`invalid param blockHash. must be 64 hexadecimal characters`); return; } if (typeof(verbosity) !== 'string') { - res.status(400).send(`invalid param verbosity ${verbosity}. must be a string representing an integer`); + res.status(400).send(`invalid param verbosity. must be a string representing an integer`); return; } const verbosityNumber = parseInt(verbosity, 10); if (typeof(verbosityNumber) !== 'number') { - res.status(400).send(`invalid param verbosity ${verbosity}. must be a valid integer`); + res.status(400).send(`invalid param verbosity. must be a valid integer`); return; } const block = await bitcoinClient.getBlock(blockHash, verbosityNumber); if (!block) { - res.status(400).send(`unable to get block for block hash ${blockHash}`); + res.status(400).send(`unable to get block`); return; } res.status(200).send(block); @@ -213,18 +217,18 @@ class BitcoinBackendRoutes { const blockHeight = req.query.height; try { if (typeof(blockHeight) !== 'string') { - res.status(400).send(`invalid param blockHeight ${blockHeight}, must be a string representing an integer`); + res.status(400).send(`invalid param blockHeight, must be a string representing an integer`); return; } const blockHeightNumber = parseInt(blockHeight, 10); if (typeof(blockHeightNumber) !== 'number') { - res.status(400).send(`invalid param blockHeight ${blockHeight}. must be a valid integer`); + res.status(400).send(`invalid param blockHeight. must be a valid integer`); return; } const block = await bitcoinClient.getBlockHash(blockHeightNumber); if (!block) { - res.status(400).send(`unable to get block hash for block height ${blockHeightNumber}`); + res.status(400).send(`unable to get block hash`); return; } res.status(200).send(block); @@ -247,4 +251,4 @@ class BitcoinBackendRoutes { } } -export default new BitcoinBackendRoutes \ No newline at end of file +export default new BitcoinBackendRoutes; \ No newline at end of file diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index d2d298e09..b56ca4861 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -21,6 +21,12 @@ import transactionRepository from '../../repositories/TransactionRepository'; import rbfCache from '../rbf-cache'; import { calculateMempoolTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; +import poolsUpdater from '../../tasks/pools-updater'; + +const TXID_REGEX = /^[a-f0-9]{64}$/i; +const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i; +const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i; +const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i; class BitcoinRoutes { public initRoutes(app: Application) { @@ -51,6 +57,10 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) + // Internal routes + .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/definition/list', this.getBlockDefinitionHashes) + .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/definition/current', this.getCurrentBlockDefinitionHash) + .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/:definitionHash', this.getBlocksByDefinitionHash) ; if (config.MEMPOOL.BACKEND !== 'esplora') { @@ -90,7 +100,7 @@ class BitcoinRoutes { res.set('Content-Type', 'application/json'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get init data'); } } @@ -109,7 +119,7 @@ class BitcoinRoutes { const result = mempoolBlocks.getMempoolBlocks(); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get mempool blocks'); } } @@ -121,7 +131,10 @@ class BitcoinRoutes { const txIds: string[] = []; for (const _txId in req.query.txId) { if (typeof req.query.txId[_txId] === 'string') { - txIds.push(req.query.txId[_txId].toString()); + const txid = req.query.txId[_txId].toString(); + if (TXID_REGEX.test(txid)) { + txIds.push(txid); + } } } @@ -140,18 +153,22 @@ class BitcoinRoutes { handleError(req, res, 400, 'Too many txids requested'); return; } + if (txids.some((txid) => !TXID_REGEX.test(txid))) { + handleError(req, res, 400, 'Invalid txids format'); + return; + } try { const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids); res.json(batchedOutspends); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get batched outspends'); } } private async $getCpfpInfo(req: Request, res: Response) { - if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { - handleError(req, res, 501, `Invalid transaction ID.`); + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); return; } @@ -184,7 +201,7 @@ class BitcoinRoutes { try { cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); } catch (e) { - handleError(req, res, 500, 'failed to get CPFP info'); + handleError(req, res, 500, 'Failed to get CPFP info'); return; } } @@ -205,6 +222,10 @@ class BitcoinRoutes { } private async getTransaction(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true); res.json(transaction); @@ -212,12 +233,18 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get transaction'); } } private async getRawTransaction(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(req.params.txId, true); res.setHeader('content-type', 'text/plain'); @@ -226,8 +253,10 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get raw transaction'); } } @@ -292,14 +321,18 @@ class BitcoinRoutes { } } catch (e: any) { if (e instanceof Error && new RegExp(notFoundError).test(e.message)) { - handleError(req, res, 404, e.message); + handleError(req, res, 404, notFoundError); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to process PSBT'); } } } private async getTransactionStatus(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true); res.json(transaction.status); @@ -307,36 +340,54 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get transaction status'); } } private async getStrippedBlockTransactions(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transactions); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block summary'); } } private async getStrippedBlockTransaction(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } + if (!TXID_REGEX.test(req.params.txid)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid); if (!transaction) { - handleError(req, res, 404, `transaction not found in summary`); + handleError(req, res, 404, `Transaction not found in summary`); return; } res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transaction); } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction from summary'); } } private async getBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const block = await blocks.$getBlock(req.params.hash); @@ -348,53 +399,69 @@ class BitcoinRoutes { } else if (blockAge > 30 * day) { cacheDuration = 10 * day; } else { - cacheDuration = 600 + cacheDuration = 600; } res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.json(block); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block'); } } private async getBlockHeader(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const blockHeader = await bitcoinApi.$getBlockHeader(req.params.hash); res.setHeader('content-type', 'text/plain'); res.send(blockHeader); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block header'); } } private async getBlockAuditSummary(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash); if (auditSummary) { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(auditSummary); } else { - handleError(req, res, 404, `audit not available`); + handleError(req, res, 404, `Audit not available`); return; } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit summary'); } } private async $getBlockTxAuditSummary(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } + if (!TXID_REGEX.test(req.params.txid)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } 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 { - handleError(req, res, 404, `transaction audit not available`); + handleError(req, res, 404, `Transaction audit not available`); return; } } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction audit summary'); } } @@ -408,7 +475,7 @@ class BitcoinRoutes { return await this.getLegacyBlocks(req, res); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } @@ -450,7 +517,7 @@ class BitcoinRoutes { res.json(await blocks.$getBlocksBetweenHeight(from, to)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } @@ -485,11 +552,15 @@ class BitcoinRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(returnBlocks); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } private async getBlockTransactions(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0); @@ -510,7 +581,7 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100); - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block transactions'); } } @@ -519,7 +590,7 @@ class BitcoinRoutes { const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10)); res.send(blockHash); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block at height'); } } @@ -528,16 +599,20 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { const addressData = await bitcoinApi.$getAddress(req.params.address); res.json(addressData); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address'); } } @@ -546,6 +621,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { let lastTxId: string = ''; @@ -556,10 +635,10 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address transactions'); } } @@ -575,6 +654,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) { + handleError(req, res, 501, `Invalid scripthash`); + return; + } try { // electrum expects scripthashes in little-endian @@ -583,10 +666,10 @@ class BitcoinRoutes { res.json(addressData); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get script hash'); } } @@ -595,6 +678,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) { + handleError(req, res, 501, `Invalid scripthash`); + return; + } try { // electrum expects scripthashes in little-endian @@ -607,10 +694,10 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get script hash transactions'); } } @@ -623,10 +710,10 @@ class BitcoinRoutes { private async getAddressPrefix(req: Request, res: Response) { try { - const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); - res.send(blockHash); + const addressPrefix = await bitcoinApi.$getAddressPrefix(req.params.prefix); + res.send(addressPrefix); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address prefix'); } } @@ -657,6 +744,52 @@ class BitcoinRoutes { } } + private async getBlockDefinitionHashes(req: Request, res: Response): Promise { + try { + const result = await blocks.$getBlockDefinitionHashes(); + if (!result) { + handleError(req, res, 503, `Service Temporarily Unavailable`); + return; + } + res.setHeader('content-type', 'application/json'); + res.send(result); + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } + + private async getCurrentBlockDefinitionHash(req: Request, res: Response): Promise { + try { + const currentSha = await poolsUpdater.getShaFromDb(); + if (!currentSha) { + handleError(req, res, 503, `Service Temporarily Unavailable`); + return; + } + res.setHeader('content-type', 'text/plain'); + res.send(currentSha); + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } + + private async getBlocksByDefinitionHash(req: Request, res: Response): Promise { + try { + if (typeof(req.params.definitionHash) !== 'string') { + res.status(400).send('Parameter "hash" must be a valid string'); + return; + } + const blocksHash = await blocks.$getBlocksByDefinitionHash(req.params.definitionHash as string); + if (!blocksHash) { + handleError(req, res, 503, `Service Temporarily Unavailable`); + return; + } + res.setHeader('content-type', 'application/json'); + res.send(blocksHash); + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } + private getBlockTipHeight(req: Request, res: Response) { try { const result = blocks.getCurrentBlockHeight(); @@ -667,7 +800,7 @@ class BitcoinRoutes { res.setHeader('content-type', 'text/plain'); res.send(result.toString()); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get height at tip'); } } @@ -677,39 +810,55 @@ class BitcoinRoutes { res.setHeader('content-type', 'text/plain'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get hash at tip'); } } private async getRawBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const result = await bitcoinApi.$getRawBlock(req.params.hash); res.setHeader('content-type', 'application/octet-stream'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get raw block'); } } private async getTxIdsForBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get txids for block'); } } private async validateAddress(req: Request, res: Response) { + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { const result = await bitcoinClient.validateAddress(req.params.address); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to validate address'); } } private async getRbfHistory(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const replacements = rbfCache.getRbfTree(req.params.txId) || null; const replaces = rbfCache.getReplaces(req.params.txId) || null; @@ -718,7 +867,7 @@ class BitcoinRoutes { replaces }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get rbf history'); } } @@ -727,7 +876,7 @@ class BitcoinRoutes { const result = rbfCache.getRbfTrees(false); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get rbf trees'); } } @@ -736,11 +885,15 @@ class BitcoinRoutes { const result = rbfCache.getRbfTrees(true); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get full rbf replacements'); } } private async getCachedTx(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const result = rbfCache.getTx(req.params.txId); if (result) { @@ -749,16 +902,20 @@ class BitcoinRoutes { res.status(204).send(); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get cached tx'); } } private async getTransactionOutspends(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const result = await bitcoinApi.$getOutspends(req.params.txId); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction outspends'); } } @@ -771,7 +928,7 @@ class BitcoinRoutes { handleError(req, res, 503, `Service Temporarily Unavailable`); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get difficulty change'); } } @@ -782,8 +939,8 @@ class BitcoinRoutes { const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); res.send(txIdResult); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to send raw transaction'); } } @@ -794,8 +951,8 @@ class BitcoinRoutes { const txIdResult = await bitcoinClient.sendRawTransaction(txHex); res.send(txIdResult); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to send raw transaction'); } } @@ -806,8 +963,8 @@ class BitcoinRoutes { const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); res.send(result); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to test transactions'); } } @@ -819,8 +976,8 @@ class BitcoinRoutes { const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined); res.send(result); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to submit package'); } } diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 2aea8e73c..8035d92c0 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -20,6 +20,7 @@ interface FailoverHost { preferred?: boolean, checked: boolean, lastChecked?: number, + publicDomain: string, hashes: { frontend?: string, backend?: string, @@ -58,6 +59,7 @@ class FailoverRouter { rtts: [], rtt: Infinity, failures: 0, + publicDomain: 'https://' + this.extractPublicDomain(domain), hashes: { lastUpdated: 0, }, @@ -71,6 +73,7 @@ class FailoverRouter { socket: !!config.ESPLORA.UNIX_SOCKET_PATH, preferred: true, checked: false, + publicDomain: `http://${this.localHostname}`, hashes: { lastUpdated: 0, }, @@ -242,7 +245,7 @@ class FailoverRouter { // methods for retrieving git hashes by host private async $updateFrontendGitHash(host: FailoverHost): Promise { try { - const url = host.socket ? `http://${this.localHostname}/resources/config.js` : `${host.host.slice(0, -4)}/resources/config.js`; + const url = `${host.publicDomain}/resources/config.js`; const response = await this.pollConnection.get(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT }); const match = response.data.match(/GIT_COMMIT_HASH\s*=\s*['"](.*?)['"]/); if (match && match[1]?.length) { @@ -255,7 +258,7 @@ class FailoverRouter { private async $updateBackendGitHash(host: FailoverHost): Promise { try { - const url = host.socket ? `http://${this.localHostname}/api/v1/backend-info` : `${host.host}/v1/backend-info`; + const url = `${host.publicDomain}/api/v1/backend-info`; const response = await this.pollConnection.get(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT }); if (response.data?.gitCommit) { host.hashes.backend = response.data.gitCommit; @@ -265,6 +268,21 @@ class FailoverRouter { } } + // returns the public mempool domain corresponding to an esplora server url + // (a bit of a hack to avoid manually specifying frontend & backend URLs for each esplora server) + private extractPublicDomain(url: string): string { + // force the url to start with a valid protocol + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + // parse as URL and extract the hostname + try { + const parsed = new URL(urlWithProtocol); + return parsed.hostname; + } catch (e) { + // fallback to the original url + return url; + } + } + private async $query(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise { let axiosConfig; let url; diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index e621056ab..102601594 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -33,8 +33,8 @@ import AccelerationRepository from '../repositories/AccelerationRepository'; import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp'; import mempool from './mempool'; import CpfpRepository from '../repositories/CpfpRepository'; -import accelerationApi from './services/acceleration'; import { parseDATUMTemplateCreator } from '../utils/bitcoin-script'; +import database from '../database'; class Blocks { private blocks: BlockExtended[] = []; @@ -1462,6 +1462,36 @@ class Blocks { // not a fatal error, we'll try again next time the indexer runs } } + + public async $getBlockDefinitionHashes(): Promise { + try { + const [rows]: any = await database.query(`SELECT DISTINCT(definition_hash) FROM blocks`); + if (rows && Array.isArray(rows)) { + return rows.map(r => r.definition_hash); + } else { + logger.debug(`Unable to retreive list of blocks.definition_hash from db (no result)`); + return null; + } + } catch (e) { + logger.debug(`Unable to retreive list of blocks.definition_hash from db (exception: ${e})`); + return null; + } + } + + public async $getBlocksByDefinitionHash(definitionHash: string): Promise { + try { + const [rows]: any = await database.query(`SELECT hash FROM blocks WHERE definition_hash = ?`, [definitionHash]); + if (rows && Array.isArray(rows)) { + return rows.map(r => r.hash); + } else { + logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (no result)`); + return null; + } + } catch (e) { + logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (exception: ${e})`); + return null; + } + } } export default new Blocks(); diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 6cb361ffd..4f43bd9d2 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 = 93; + private static currentVersion = 95; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -801,6 +801,335 @@ class DatabaseMigration { `); await this.updateToSchemaVersion(93); } + + // Unify database schema for all mempool netwoks + // versions above 94 should not use network-specific flags + if (databaseSchemaVersion < 94) { + + if (!isBitcoin) { + // Apply all the bitcoin specific migrations to non-bitcoin networks: liquid, liquidtestnet and testnet4 (!) + // Version 5 + await this.$executeQuery('ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"'); + + // Version 6 + await this.$executeQuery('ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`'); + await this.$executeQuery('ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL'); + await this.$executeQuery('ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)'); + await this.$executeQuery('ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""'); + await this.$executeQuery('ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL'); + + // Version 7 + await this.$executeQuery('DROP table IF EXISTS hashrates;'); + await this.$executeQuery(this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates')); + + // Version 8 + await this.$executeQuery('ALTER TABLE `hashrates` DROP INDEX `PRIMARY`'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `share` float NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"'); + + // Version 9 + await this.$executeQuery('ALTER TABLE `state` CHANGE `name` `name` varchar(100)'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)'); + + // Version 10 + await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)'); + + // Version 11 + await this.$executeQuery(`ALTER TABLE blocks + ADD avg_fee INT UNSIGNED NULL, + ADD avg_fee_rate INT UNSIGNED NULL + `); + await this.$executeQuery('ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 12 + await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 13 + await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` DOUBLE UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee_rate` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 14 + await this.$executeQuery('ALTER TABLE `hashrates` DROP FOREIGN KEY `hashrates_ibfk_1`'); + await this.$executeQuery('ALTER TABLE `hashrates` MODIFY `pool_id` SMALLINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 17 + await this.$executeQuery('ALTER TABLE `pools` ADD `slug` CHAR(50) NULL'); + + // Version 18 + await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `hash` (`hash`);'); + + // Version 20 + await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries')); + + // Version 22 + await this.$executeQuery('DROP TABLE IF EXISTS `difficulty_adjustments`'); + await this.$executeQuery(this.getCreateDifficultyAdjustmentsTableQuery(), await this.$checkIfTableExists('difficulty_adjustments')); + + // Version 24 + await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`'); + await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits')); + + // Version 25 + await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats')); + await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); + await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); + await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats')); + + // Version 26 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"'); + + // Version 27 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); + + // Version 28 + await this.$executeQuery(`ALTER TABLE lightning_stats MODIFY added DATE`); + + // Version 29 + await this.$executeQuery(this.getCreateGeoNamesTableQuery(), await this.$checkIfTableExists('geo_names')); + await this.$executeQuery('ALTER TABLE `nodes` ADD as_number int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD city_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD country_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD accuracy_radius int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD subdivision_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD longitude double NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD latitude double NULL DEFAULT NULL'); + + // Version 30 + await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization") NOT NULL'); + + // Version 31 + await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE'); + await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`'); + await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices')); + + // Version 32 + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"'); + + // Version 33 + await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL'); + + // Version 34 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_tor_nodes int(11) NOT NULL DEFAULT "0"'); + + // Version 35 + await this.$executeQuery('DELETE from `lightning_stats` WHERE added > "2021-09-19"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD CONSTRAINT added_unique UNIQUE (added);'); + + // Version 36 + await this.$executeQuery('ALTER TABLE `nodes` ADD status TINYINT NOT NULL DEFAULT "1"'); + + // Version 37 + await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets')); + + // Version 38 + await this.$executeQuery(`TRUNCATE lightning_stats`); + await this.$executeQuery(`TRUNCATE node_stats`); + await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL'); + await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL'); + await this.updateToSchemaVersion(38); + + // Version 39 + await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`'); + await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)'); + + // Version 40 + await this.$executeQuery('ALTER TABLE `nodes` ADD capacity bigint(20) unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);'); + + // Version 41 + await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1'); + + // Version 42 + await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0'); + + // Version 43 + await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records')); + + // Version 44 + await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); + + // Version 45 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"'); + + // Version 48 + await this.$executeQuery('ALTER TABLE `channels` ADD source_checked tinyint(1) DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD closing_fee bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node1_funding_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node2_funding_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node1_closing_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node2_closing_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD funding_ratio float unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `channels` ADD closed_by varchar(66) DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `channels` ADD single_funded tinyint(1) DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD outputs JSON DEFAULT "[]"'); + + // Version 57 + await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`); + + // Version 60 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD sigop_txs JSON DEFAULT "[]"'); + + // Version 61 + if (! await this.$checkIfTableExists('blocks_templates')) { + await this.$executeQuery('CREATE TABLE blocks_templates AS SELECT id, template FROM blocks_summaries WHERE template != "[]"'); + } + await this.$executeQuery('ALTER TABLE blocks_templates MODIFY template JSON DEFAULT "[]"'); + await this.$executeQuery('ALTER TABLE blocks_templates ADD PRIMARY KEY (id)'); + await this.$executeQuery('ALTER TABLE blocks_summaries DROP COLUMN template'); + + // Version 62 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL'); + + // Version 63 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"'); + + // Version 64 + await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL'); + + // Version 65 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD accelerated_txs JSON DEFAULT "[]"'); + + // Version 67 + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD INDEX `version` (`version`)'); + await this.$executeQuery('ALTER TABLE `blocks_templates` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)'); + + // Version 76 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD prioritized_txs JSON DEFAULT "[]"'); + + // Version 81 + 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 "[]"'); + + // Version 83 + await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL'); + + // Version 84 + await this.$executeQuery(` + ALTER TABLE \`pools\` + ADD INDEX \`slug\` (\`slug\`), + ADD INDEX \`unique_id\` (\`unique_id\`) + `); + + // Version 85 + await this.$executeQuery(` + ALTER TABLE \`channels\` + ADD INDEX \`created\` (\`created\`), + ADD INDEX \`capacity\` (\`capacity\`), + ADD INDEX \`closing_reason\` (\`closing_reason\`), + ADD INDEX \`closing_resolved\` (\`closing_resolved\`) + `); + + // Version 86 + await this.$executeQuery(` + ALTER TABLE \`nodes\` + ADD INDEX \`status\` (\`status\`), + ADD INDEX \`channels\` (\`channels\`), + ADD INDEX \`country_id\` (\`country_id\`), + ADD INDEX \`as_number\` (\`as_number\`), + ADD INDEX \`first_seen\` (\`first_seen\`) + `); + + // Version 87 + await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)'); + await this.updateToSchemaVersion(87); + + // Version 88 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)'); + + // Version 89 + await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)'); + + // Version 90 + await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)'); + + // Version 91 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)'); + } + + if (config.MEMPOOL.NETWORK !== 'liquid') { + // Apply all the liquid specific migrations to all other networks + // Version 68 + await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);'); + await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses')); + await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos')); + + // Version 71 + await this.$executeQuery('ALTER TABLE `federation_txos` ADD timelock INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `federation_txos` ADD expiredAt INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `federation_txos` ADD emergencyKey TINYINT NOT NULL DEFAULT 0'); + + // Version 92 + await this.$executeQuery(` + ALTER TABLE \`elements_pegs\` + ADD INDEX \`block\` (\`block\`), + ADD INDEX \`datetime\` (\`datetime\`), + ADD INDEX \`amount\` (\`amount\`), + ADD INDEX \`bitcoinaddress\` (\`bitcoinaddress\`), + ADD INDEX \`bitcointxid\` (\`bitcointxid\`) + `); + + // Version 93 + await this.$executeQuery(` + ALTER TABLE \`federation_txos\` + ADD INDEX \`unspent\` (\`unspent\`), + ADD INDEX \`lastblockupdate\` (\`lastblockupdate\`), + ADD INDEX \`blocktime\` (\`blocktime\`), + ADD INDEX \`emergencyKey\` (\`emergencyKey\`), + ADD INDEX \`expiredAt\` (\`expiredAt\`) + `); + } + + if (config.MEMPOOL.NETWORK !== 'mainnet') { + // Apply all the mainnet specific migrations to all other networks + // Version 69 + await this.$executeQuery(this.getCreateAccelerationsTableQuery(), await this.$checkIfTableExists('accelerations')); + + // Version 70 + await this.$executeQuery('ALTER TABLE accelerations MODIFY COLUMN added DATETIME;'); + + // Version 77 + await this.$executeQuery('ALTER TABLE `accelerations` ADD requested datetime DEFAULT NULL'); + } + await this.updateToSchemaVersion(94); + } + + // blocks pools-v2.json hash + if (databaseSchemaVersion < 95) { + let poolJsonSha = 'f737d86571d190cf1a1a3cf5fd86b33ba9624254'; + const [poolJsonShaDb]: any[] = await DB.query(`SELECT string FROM state WHERE name = 'pools_json_sha'`); + if (poolJsonShaDb?.length > 0) { + poolJsonSha = poolJsonShaDb[0].string; + } + await this.$executeQuery(`ALTER TABLE blocks ADD definition_hash varchar(255) NOT NULL DEFAULT "${poolJsonSha}"`); + await this.$executeQuery('ALTER TABLE blocks ADD INDEX `definition_hash` (`definition_hash`)'); + await this.updateToSchemaVersion(95); + } } /** diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index 8b4c3e8c8..031aeea17 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -3,6 +3,8 @@ import { Application, Request, Response } from 'express'; import channelsApi from './channels.api'; import { handleError } from '../../utils/api'; +const TXID_REGEX = /^[a-f0-9]{64}$/i; + class ChannelsRoutes { constructor() { } @@ -23,7 +25,7 @@ class ChannelsRoutes { const channels = await channelsApi.$searchChannelsById(req.params.search); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search channels by id'); } } @@ -39,7 +41,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channel); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channel'); } } @@ -70,7 +72,7 @@ class ChannelsRoutes { res.header('X-Total-Count', channelsCount.toString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channels for node'); } } @@ -83,7 +85,10 @@ class ChannelsRoutes { const txIds: string[] = []; for (const _txId in req.query.txId) { if (typeof req.query.txId[_txId] === 'string') { - txIds.push(req.query.txId[_txId].toString()); + const txid = req.query.txId[_txId].toString(); + if (TXID_REGEX.test(txid)) { + txIds.push(txid); + } } } const channels = await channelsApi.$getChannelsByTransactionId(txIds); @@ -108,7 +113,7 @@ class ChannelsRoutes { res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channels by transaction ids'); } } @@ -120,7 +125,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get penalty closed channels'); } } @@ -133,7 +138,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channel geodata'); } } diff --git a/backend/src/api/explorer/general.routes.ts b/backend/src/api/explorer/general.routes.ts index b4d0c635d..f974c9810 100644 --- a/backend/src/api/explorer/general.routes.ts +++ b/backend/src/api/explorer/general.routes.ts @@ -29,7 +29,7 @@ class GeneralLightningRoutes { channels: channels, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search for nodes and channels'); } } @@ -43,7 +43,7 @@ class GeneralLightningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get lightning statistics'); } } @@ -52,7 +52,7 @@ class GeneralLightningRoutes { const statistics = await statisticsApi.$getLatestStatistics(); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get lightning statistics'); } } } diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 9ca2fd1c3..811292b4b 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -32,7 +32,7 @@ class NodesRoutes { const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search); res.json(nodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search for node'); } } @@ -188,7 +188,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(nodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get node group'); } } @@ -204,7 +204,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(node); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get node'); } } @@ -216,7 +216,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical node stats'); } } @@ -232,7 +232,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(node); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get fee histogram'); } } @@ -248,7 +248,7 @@ class NodesRoutes { topByChannels: topChannelsNodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes ranking'); } } @@ -260,7 +260,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get top nodes by capacity'); } } @@ -272,7 +272,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get top nodes by channels'); } } @@ -284,7 +284,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get oldest nodes'); } } @@ -296,7 +296,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(nodesPerAs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get ISP ranking'); } } @@ -308,7 +308,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(worldNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get world nodes'); } } @@ -336,7 +336,7 @@ class NodesRoutes { nodes: nodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per country'); } } @@ -363,7 +363,7 @@ class NodesRoutes { nodes: nodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per ISP'); } } @@ -375,7 +375,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(nodesPerAs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per country'); } } } diff --git a/backend/src/api/liquid/liquid.routes.ts b/backend/src/api/liquid/liquid.routes.ts index 9dafd0def..388038f7f 100644 --- a/backend/src/api/liquid/liquid.routes.ts +++ b/backend/src/api/liquid/liquid.routes.ts @@ -83,7 +83,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.json(pegs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs by month'); } } @@ -95,7 +95,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.json(reserves); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get reserves by month'); } } @@ -107,7 +107,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(currentSupply); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs'); } } @@ -119,7 +119,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(currentReserves); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get reserves'); } } @@ -131,7 +131,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(auditStatus); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation audit status'); } } @@ -143,7 +143,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationAddresses); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation addresses'); } } @@ -155,7 +155,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationAddresses); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation addresses'); } } @@ -167,7 +167,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation utxos'); } } @@ -179,7 +179,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(expiredUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get expired utxos'); } } @@ -191,7 +191,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation utxos number'); } } @@ -203,7 +203,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(emergencySpentUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get emergency spent utxos'); } } @@ -215,7 +215,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(emergencySpentUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get emergency spent utxos stats'); } } @@ -227,7 +227,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(recentPegs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs list'); } } @@ -239,7 +239,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(pegsVolume); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs volume daily'); } } @@ -251,7 +251,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(pegsCount); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs count'); } } diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 9af43c087..ede047eed 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -72,7 +72,7 @@ class MiningRoutes { } res.status(200).send(response); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical prices'); } } @@ -87,7 +87,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pool'); } } } @@ -106,7 +106,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks for pool'); } } } @@ -130,7 +130,7 @@ class MiningRoutes { res.json(pools); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools'); } } @@ -144,7 +144,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(stats); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools'); } } @@ -158,7 +158,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(hashrates); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools historical hashrate'); } } @@ -175,7 +175,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pool historical hashrate'); } } } @@ -204,7 +204,7 @@ class MiningRoutes { currentDifficulty: currentDifficulty, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical hashrate'); } } @@ -218,7 +218,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFees); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fees'); } } @@ -236,7 +236,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFees); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fees'); } } @@ -250,7 +250,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockRewards); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block rewards'); } } @@ -264,7 +264,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFeeRates); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fee rates'); } } @@ -282,7 +282,7 @@ class MiningRoutes { weights: blockWeights }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block size and weight'); } } @@ -294,7 +294,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment])); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical difficulty adjustments'); } } @@ -304,7 +304,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(response); } catch (e) { - res.status(500).end(); + handleError(req, res, 500, 'Failed to get reward stats'); } } @@ -318,7 +318,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate])); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical blocks health'); } } @@ -336,7 +336,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.json(audit); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit'); } } @@ -359,7 +359,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get height from timestamp'); } } @@ -372,7 +372,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit scores'); } } @@ -385,7 +385,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.json(audit || 'null'); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit score'); } } @@ -400,7 +400,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get accelerations by pool'); } } @@ -416,7 +416,7 @@ class MiningRoutes { const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get accelerations by height'); } } @@ -431,7 +431,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get recent accelerations'); } } @@ -446,7 +446,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationTotals(req.query.pool, req.query.interval)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get acceleration totals'); } } @@ -461,7 +461,7 @@ class MiningRoutes { } res.status(200).send(Object.values(accelerationApi.getAccelerations() || {})); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get active accelerations'); } } @@ -473,7 +473,7 @@ class MiningRoutes { accelerationApi.accelerationRequested(req.params.txid); res.status(200).send(); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to request acceleration'); } } } diff --git a/backend/src/api/pools-parser.ts b/backend/src/api/pools-parser.ts index 289389d5e..2fd55d6c5 100644 --- a/backend/src/api/pools-parser.ts +++ b/backend/src/api/pools-parser.ts @@ -19,15 +19,6 @@ class PoolsParser { 'addresses': '[]', 'slug': 'unknown' }; - private uniqueLogs: string[] = []; - - private uniqueLog(loggerFunction: any, msg: string): void { - if (this.uniqueLogs.includes(msg)) { - return; - } - this.uniqueLogs.push(msg); - loggerFunction(msg); - } public setMiningPools(pools): void { for (const pool of pools) { diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 944ad790e..df6e10c77 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -119,7 +119,11 @@ class RbfCache { public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void { - if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { + if ( !newTxExtended + || !replaced?.length + || this.txs.has(newTxExtended.txid) + || !(replaced.some(tx => !this.replacedBy.has(tx.txid))) + ) { return; } diff --git a/backend/src/api/services/services-routes.ts b/backend/src/api/services/services-routes.ts index cff163174..520496249 100644 --- a/backend/src/api/services/services-routes.ts +++ b/backend/src/api/services/services-routes.ts @@ -1,6 +1,7 @@ import { Application, Request, Response } from 'express'; import config from '../../config'; import WalletApi from './wallets'; +import { handleError } from '../../utils/api'; class ServicesRoutes { public initRoutes(app: Application): void { @@ -18,7 +19,7 @@ class ServicesRoutes { const wallet = await WalletApi.getWallet(walletId); res.status(200).send(wallet); } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get wallet'); } } } diff --git a/backend/src/api/services/stratum.ts b/backend/src/api/services/stratum.ts new file mode 100644 index 000000000..a8ee64106 --- /dev/null +++ b/backend/src/api/services/stratum.ts @@ -0,0 +1,105 @@ +import { WebSocket } from 'ws'; +import logger from '../../logger'; +import config from '../../config'; +import websocketHandler from '../websocket-handler'; + +export interface StratumJob { + pool: number; + height: number; + coinbase: string; + scriptsig: string; + reward: number; + jobId: string; + extraNonce: string; + extraNonce2Size: number; + prevHash: string; + coinbase1: string; + coinbase2: string; + merkleBranches: string[]; + version: string; + bits: string; + time: string; + timestamp: number; + cleanJobs: boolean; + received: number; +} + +function isStratumJob(obj: any): obj is StratumJob { + return obj + && typeof obj === 'object' + && 'pool' in obj + && 'prevHash' in obj + && 'height' in obj + && 'received' in obj + && 'version' in obj + && 'timestamp' in obj + && 'bits' in obj + && 'merkleBranches' in obj + && 'cleanJobs' in obj; +} + +class StratumApi { + private ws: WebSocket | null = null; + private runWebsocketLoop: boolean = false; + private startedWebsocketLoop: boolean = false; + private websocketConnected: boolean = false; + private jobs: Record = {}; + + public constructor() {} + + public getJobs(): Record { + return this.jobs; + } + + private handleWebsocketMessage(msg: any): void { + if (isStratumJob(msg)) { + this.jobs[msg.pool] = msg; + websocketHandler.handleNewStratumJob(this.jobs[msg.pool]); + } + } + + public async connectWebsocket(): Promise { + if (!config.STRATUM.ENABLED) { + return; + } + this.runWebsocketLoop = true; + if (this.startedWebsocketLoop) { + return; + } + while (this.runWebsocketLoop) { + this.startedWebsocketLoop = true; + if (!this.ws) { + this.ws = new WebSocket(`${config.STRATUM.API}`); + this.websocketConnected = true; + + this.ws.on('open', () => { + logger.info('Stratum websocket opened'); + }); + + this.ws.on('error', (error) => { + logger.err('Stratum websocket error: ' + error); + this.ws = null; + this.websocketConnected = false; + }); + + this.ws.on('close', () => { + logger.info('Stratum websocket closed'); + this.ws = null; + this.websocketConnected = false; + }); + + this.ws.on('message', (data, isBinary) => { + try { + const parsedMsg = JSON.parse((isBinary ? data : data.toString()) as string); + this.handleWebsocketMessage(parsedMsg); + } catch (e) { + logger.warn('Failed to parse stratum websocket message: ' + (e instanceof Error ? e.message : e)); + } + }); + } + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } +} + +export default new StratumApi(); \ No newline at end of file diff --git a/backend/src/api/statistics/statistics.routes.ts b/backend/src/api/statistics/statistics.routes.ts index 31db5198c..ec05bf032 100644 --- a/backend/src/api/statistics/statistics.routes.ts +++ b/backend/src/api/statistics/statistics.routes.ts @@ -1,7 +1,7 @@ import { Application, Request, Response } from 'express'; import config from '../../config'; import statisticsApi from './statistics-api'; - +import { handleError } from '../../utils/api'; class StatisticsRoutes { public initRoutes(app: Application) { app @@ -65,7 +65,7 @@ class StatisticsRoutes { } res.json(result); } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get statistics'); } } } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 13e27c360..390896caa 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -38,6 +38,7 @@ interface AddressTransactions { import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import { calculateMempoolTxCpfp } from './cpfp'; import { getRecentFirstSeen } from '../utils/file-read'; +import stratumApi, { StratumJob } from './services/stratum'; // valid 'want' subscriptions const wantable = [ @@ -403,6 +404,16 @@ class WebsocketHandler { delete client['track-mempool']; } + if (parsedMessage && parsedMessage['track-stratum'] != null) { + if (parsedMessage['track-stratum']) { + const sub = parsedMessage['track-stratum']; + client['track-stratum'] = sub; + response['stratumJobs'] = this.socketData['stratumJobs']; + } else { + client['track-stratum'] = false; + } + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -1384,6 +1395,23 @@ class WebsocketHandler { await statistics.runStatistics(); } + public handleNewStratumJob(job: StratumJob): void { + this.updateSocketDataFields({ 'stratumJobs': stratumApi.getJobs() }); + + for (const server of this.webSocketServers) { + server.clients.forEach((client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + if (client['track-stratum'] && (client['track-stratum'] === 'all' || client['track-stratum'] === job.pool)) { + client.send(JSON.stringify({ + 'stratumJob': job + })); + } + }); + } + } + // takes a dictionary of JSON serialized values // and zips it together into a valid JSON object private serializeResponse(response): string { diff --git a/backend/src/config.ts b/backend/src/config.ts index 794421551..a1050a7d5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -165,6 +165,10 @@ interface IConfig { WALLETS: { ENABLED: boolean; WALLETS: string[]; + }, + STRATUM: { + ENABLED: boolean; + API: string; } } @@ -332,6 +336,10 @@ const defaults: IConfig = { 'ENABLED': false, 'WALLETS': [], }, + 'STRATUM': { + 'ENABLED': false, + 'API': 'http://localhost:1234', + } }; class Config implements IConfig { @@ -354,6 +362,7 @@ class Config implements IConfig { REDIS: IConfig['REDIS']; FIAT_PRICE: IConfig['FIAT_PRICE']; WALLETS: IConfig['WALLETS']; + STRATUM: IConfig['STRATUM']; constructor() { const configs = this.merge(configFromFile, defaults); @@ -376,6 +385,7 @@ class Config implements IConfig { this.REDIS = configs.REDIS; this.FIAT_PRICE = configs.FIAT_PRICE; this.WALLETS = configs.WALLETS; + this.STRATUM = configs.STRATUM; } merge = (...objects: object[]): IConfig => { diff --git a/backend/src/index.ts b/backend/src/index.ts index d939b7423..dc6a8ae1a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,6 +48,7 @@ import accelerationRoutes from './api/acceleration/acceleration.routes'; import aboutRoutes from './api/about.routes'; import mempoolBlocks from './api/mempool-blocks'; import walletApi from './api/services/wallets'; +import stratumApi from './api/services/stratum'; class Server { private wss: WebSocket.Server | undefined; @@ -320,11 +321,16 @@ class Server { loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); accelerationApi.connectWebsocket(); + if (config.STRATUM.ENABLED) { + stratumApi.connectWebsocket(); + } } setUpHttpApiRoutes(): void { bitcoinRoutes.initRoutes(this.app); - bitcoinCoreRoutes.initRoutes(this.app); + if (config.MEMPOOL.OFFICIAL) { + bitcoinCoreRoutes.initRoutes(this.app); + } pricesRoutes.initRoutes(this.app); if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) { statisticsRoutes.initRoutes(this.app); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index dc703af21..e1da4be6f 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -325,6 +325,8 @@ export interface BlockExtension { // Requires coinstatsindex, will be set to NULL otherwise utxoSetSize: number | null; totalInputAmt: number | null; + // pools-v2.json git hash + definitionHash: string | undefined; } /** diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 424a668c7..f673e574b 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -15,6 +15,7 @@ import blocks from '../api/blocks'; import BlocksAuditsRepository from './BlocksAuditsRepository'; import transactionUtils from '../api/transaction-utils'; import { parseDATUMTemplateCreator } from '../utils/bitcoin-script'; +import poolsUpdater from '../tasks/pools-updater'; interface DatabaseBlock { id: string; @@ -114,16 +115,16 @@ class BlocksRepository { try { const query = `INSERT INTO blocks( - height, hash, blockTimestamp, size, - weight, tx_count, coinbase_raw, difficulty, - 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, 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, - median_fee_amt, coinbase_signature_ascii + height, hash, blockTimestamp, size, + weight, tx_count, coinbase_raw, difficulty, + 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, 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, + median_fee_amt, coinbase_signature_ascii, definition_hash ) VALUE ( ?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, @@ -134,7 +135,7 @@ class BlocksRepository { ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? )`; const poolDbId = await PoolsRepository.$getPoolByUniqueId(block.extras.pool.id); @@ -181,6 +182,7 @@ class BlocksRepository { block.extras.segwitTotalWeight, block.extras.medianFeeAmt, truncatedCoinbaseSignatureAscii, + poolsUpdater.currentSha ]; await DB.query(query, params); @@ -1013,9 +1015,9 @@ class BlocksRepository { public async $savePool(id: string, poolId: number): Promise { try { await DB.query(` - UPDATE blocks SET pool_id = ? + UPDATE blocks SET pool_id = ?, definition_hash = ? WHERE hash = ?`, - [poolId, id] + [poolId, poolsUpdater.currentSha, id] ); } catch (e) { logger.err(`Cannot update block pool. Reason: ` + (e instanceof Error ? e.message : e)); diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts index 652383a2a..38e816d74 100644 --- a/backend/src/tasks/pools-updater.ts +++ b/backend/src/tasks/pools-updater.ts @@ -88,8 +88,8 @@ class PoolsUpdater { try { await DB.query('START TRANSACTION;'); - await poolsParser.migratePoolsJson(); await this.updateDBSha(githubSha); + await poolsParser.migratePoolsJson(); await DB.query('COMMIT;'); } catch (e) { logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, this.tag); @@ -121,7 +121,7 @@ class PoolsUpdater { /** * Fetch our latest pools-v2.json sha from the db */ - private async getShaFromDb(): Promise { + public async getShaFromDb(): Promise { try { const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"'); return (rows.length > 0 ? rows[0].string : null); diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index c7ade9b7b..ee8e329a6 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -148,6 +148,10 @@ "API": "__MEMPOOL_SERVICES_API__", "ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__ }, + "STRATUM": { + "ENABLED": __STRATUM_ENABLED__, + "API": "__STRATUM_API__" + }, "REDIS": { "ENABLED": __REDIS_ENABLED__, "UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__", diff --git a/docker/backend/start.sh b/docker/backend/start.sh index d4765972e..8adb631da 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -149,6 +149,10 @@ __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} __MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"} __MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false} +# STRATUM +__STRATUM_ENABLED__=${STRATUM_ENABLED:=false} +__STRATUM_API__=${STRATUM_API:="http://localhost:1234"} + # REDIS __REDIS_ENABLED__=${REDIS_ENABLED:=false} __REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""} @@ -300,6 +304,10 @@ sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.j sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json +# STRATUM +sed -i "s!__STRATUM_ENABLED__!${__STRATUM_ENABLED__}!g" mempool-config.json +sed -i "s!__STRATUM_API__!${__STRATUM_API__}!g" mempool-config.json + # REDIS sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index 2086188c9..9727adb2d 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -45,6 +45,7 @@ __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} +__STRATUM_ENABLED__=${STRATUM_ENABLED:=false} # Export as environment variables to be used by envsubst export __MAINNET_ENABLED__ @@ -76,6 +77,7 @@ export __SERVICES_API__ export __PUBLIC_ACCELERATIONS__ export __HISTORICAL_PRICE__ export __ADDITIONAL_CURRENCIES__ +export __STRATUM_ENABLED__ folder=$(find /var/www/mempool -name "config.js" | xargs dirname) echo ${folder} diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index a1082b769..7e17c09cd 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -344,7 +344,9 @@ describe('Mainnet', () => { cy.visit('/'); cy.waitForSkeletonGone(); - cy.changeNetwork('testnet4'); + //TODO(knorrium): add a check for the proxied server + // cy.changeNetwork('testnet4'); + cy.changeNetwork('signet'); cy.changeNetwork('mainnet'); }); diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index f9f2576d6..70dc2edba 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -27,5 +27,6 @@ "ACCELERATOR": false, "ACCELERATOR_BUTTON": true, "PUBLIC_ACCELERATIONS": false, + "STRATUM_ENABLED": false, "SERVICES_API": "https://mempool.space/api/v1/services" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a27bffcb4..932979e2b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,9 +23,9 @@ "@angular/router": "^17.3.1", "@angular/ssr": "^17.3.1", "@fortawesome/angular-fontawesome": "~0.14.1", - "@fortawesome/fontawesome-common-types": "~6.6.0", - "@fortawesome/fontawesome-svg-core": "~6.6.0", - "@fortawesome/free-solid-svg-icons": "~6.6.0", + "@fortawesome/fontawesome-common-types": "~6.7.2", + "@fortawesome/fontawesome-svg-core": "~6.7.2", + "@fortawesome/free-solid-svg-icons": "~6.7.2", "@mempool/mempool.js": "2.3.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0", "@types/qrcode": "~1.5.0", @@ -33,9 +33,8 @@ "browserify": "^17.0.0", "clipboard": "^2.0.11", "domino": "^2.1.6", - "echarts": "~5.5.0", + "echarts": "~5.6.0", "esbuild": "^0.24.0", - "lightweight-charts": "~3.8.0", "ngx-echarts": "~17.2.0", "ngx-infinite-scroll": "^17.0.0", "qrcode": "1.5.1", @@ -62,7 +61,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.15.0", + "cypress": "^13.17.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", @@ -3113,9 +3112,10 @@ } }, "node_modules/@cypress/request": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", - "integrity": "sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.7.tgz", + "integrity": "sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==", + "license": "Apache-2.0", "optional": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -3131,9 +3131,9 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.13.0", + "qs": "6.13.1", "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", + "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" }, @@ -3141,6 +3141,22 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@cypress/schematic": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@cypress/schematic/-/schematic-2.5.0.tgz", @@ -3674,30 +3690,33 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "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==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", - "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.2" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "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==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.2" }, "engines": { "node": ">=6" @@ -5673,6 +5692,7 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", "optional": true, "dependencies": { "safer-buffer": "~2.1.0" @@ -5707,6 +5727,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", "optional": true, "engines": { "node": ">=0.8" @@ -5827,6 +5848,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", "optional": true, "engines": { "node": "*" @@ -5836,6 +5858,7 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", "optional": true }, "node_modules/axios": { @@ -5993,6 +6016,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", "optional": true, "dependencies": { "tweetnacl": "^0.14.3" @@ -7068,6 +7092,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", "optional": true }, "node_modules/chai": { @@ -7170,15 +7195,16 @@ } }, "node_modules/ci-info": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", - "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz", + "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "optional": true, "engines": { "node": ">=8" @@ -7953,13 +7979,14 @@ "peer": true }, "node_modules/cypress": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", - "integrity": "sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw==", + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz", + "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==", "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { - "@cypress/request": "^3.0.4", + "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -7970,6 +7997,7 @@ "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", + "ci-info": "^4.0.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.1", "commander": "^6.2.1", @@ -7984,7 +8012,6 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -7999,6 +8026,7 @@ "semver": "^7.5.3", "supports-color": "^8.1.1", "tmp": "~0.2.3", + "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -8201,6 +8229,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", "optional": true, "dependencies": { "assert-plus": "^1.0.0" @@ -8687,6 +8716,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", "optional": true, "dependencies": { "jsbn": "~0.1.0", @@ -8694,12 +8724,12 @@ } }, "node_modules/echarts": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.0.tgz", - "integrity": "sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "dependencies": { "tslib": "2.3.0", - "zrender": "5.5.0" + "zrender": "5.6.1" } }, "node_modules/echarts/node_modules/tslib": { @@ -9905,6 +9935,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "optional": true }, "node_modules/falafel": { @@ -9921,11 +9952,6 @@ "node": ">=0.4.0" } }, - "node_modules/fancy-canvas": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz", - "integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA==" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10193,6 +10219,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", "optional": true, "engines": { "node": "*" @@ -10400,6 +10427,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", "optional": true, "dependencies": { "assert-plus": "^1.0.0" @@ -10854,6 +10882,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "license": "MIT", "optional": true, "dependencies": { "assert-plus": "^1.0.0", @@ -11220,18 +11249,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "optional": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -11481,6 +11498,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", "optional": true }, "node_modules/is-unicode-supported": { @@ -11545,6 +11563,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", "optional": true }, "node_modules/istanbul-lib-coverage": { @@ -11678,6 +11697,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", "optional": true }, "node_modules/jsesc": { @@ -11706,6 +11726,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", "optional": true }, "node_modules/json-schema-traverse": { @@ -11723,6 +11744,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", "optional": true }, "node_modules/json5": { @@ -11783,6 +11805,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "optional": true, "dependencies": { "assert-plus": "1.0.0", @@ -12106,14 +12129,6 @@ } } }, - "node_modules/lightweight-charts": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.8.0.tgz", - "integrity": "sha512-7yFGnYuE1RjRJG9RwUTBz5wvF1QtjBOSW4FFlikr8Dh+/TDNt4ci+HsWSYmStgQUpawpvkCJ3j5/W25GppGj9Q==", - "dependencies": { - "fancy-canvas": "0.2.2" - } - }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -14110,6 +14125,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", "optional": true }, "node_modules/picocolors": { @@ -14540,12 +14556,6 @@ "node": ">= 0.10" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "optional": true - }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -14661,12 +14671,6 @@ "node": ">=0.4.x" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "optional": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -16028,6 +16032,7 @@ "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", "optional": true, "dependencies": { "asn1": "~0.2.3", @@ -16577,6 +16582,26 @@ "readable-stream": "3" } }, + "node_modules/tldts": { + "version": "6.1.70", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.70.tgz", + "integrity": "sha512-/W1YVgYVJd9ZDjey5NXadNh0mJXkiUMUue9Zebd0vpdo1sU+H4zFFTaJ1RKD4N6KFoHfcXy6l+Vu7bh+bdWCzA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tldts-core": "^6.1.70" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.70", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.70.tgz", + "integrity": "sha512-RNnIXDB1FD4T9cpQRErEqw6ZpjLlGdMOitdV+0xtbsnwr4YFka1zpc7D4KD+aAn8oSG5JyFrdasZTE04qDE9Yg==", + "license": "MIT", + "optional": true + }, "node_modules/tlite": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz", @@ -16621,27 +16646,16 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "license": "BSD-3-Clause", "optional": true, "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "optional": true, - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/transform-ast": { @@ -16810,6 +16824,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", "optional": true, "dependencies": { "safe-buffer": "^5.0.1" @@ -16822,6 +16837,7 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", "optional": true }, "node_modules/type": { @@ -17130,16 +17146,6 @@ "querystring": "0.2.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "optional": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", @@ -17207,6 +17213,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "optional": true, "dependencies": { "assert-plus": "^1.0.0", @@ -18359,9 +18366,9 @@ } }, "node_modules/zrender": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.5.0.tgz", - "integrity": "sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", "dependencies": { "tslib": "2.3.0" } @@ -20348,9 +20355,9 @@ } }, "@cypress/request": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", - "integrity": "sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.7.tgz", + "integrity": "sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==", "optional": true, "requires": { "aws-sign2": "~0.7.0", @@ -20366,11 +20373,22 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.13.0", + "qs": "6.13.1", "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", + "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" + }, + "dependencies": { + "qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "optional": true, + "requires": { + "side-channel": "^1.0.6" + } + } } }, "@cypress/schematic": { @@ -20649,24 +20667,24 @@ } }, "@fortawesome/fontawesome-common-types": { - "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==" + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==" }, "@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", - "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "requires": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.2" } }, "@fortawesome/free-solid-svg-icons": { - "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==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", "requires": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.2" } }, "@goto-bus-stop/common-shake": { @@ -23298,9 +23316,9 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" }, "ci-info": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", - "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz", + "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", "optional": true }, "cipher-base": { @@ -23896,12 +23914,12 @@ "peer": true }, "cypress": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", - "integrity": "sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw==", + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz", + "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==", "optional": true, "requires": { - "@cypress/request": "^3.0.4", + "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -23912,6 +23930,7 @@ "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", + "ci-info": "^4.0.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.1", "commander": "^6.2.1", @@ -23926,7 +23945,6 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -23941,6 +23959,7 @@ "semver": "^7.5.3", "supports-color": "^8.1.1", "tmp": "~0.2.3", + "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -24466,12 +24485,12 @@ } }, "echarts": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.0.tgz", - "integrity": "sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "requires": { "tslib": "2.3.0", - "zrender": "5.5.0" + "zrender": "5.6.1" }, "dependencies": { "tslib": { @@ -25433,11 +25452,6 @@ "object-keys": "^1.0.6" } }, - "fancy-canvas": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz", - "integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA==" - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -26373,15 +26387,6 @@ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==" }, - "is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "optional": true, - "requires": { - "ci-info": "^3.2.0" - } - }, "is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -27015,14 +27020,6 @@ "webpack-sources": "^3.0.0" } }, - "lightweight-charts": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.8.0.tgz", - "integrity": "sha512-7yFGnYuE1RjRJG9RwUTBz5wvF1QtjBOSW4FFlikr8Dh+/TDNt4ci+HsWSYmStgQUpawpvkCJ3j5/W25GppGj9Q==", - "requires": { - "fancy-canvas": "0.2.2" - } - }, "limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -28806,12 +28803,6 @@ "event-stream": "=3.3.4" } }, - "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "optional": true - }, "public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -28903,12 +28894,6 @@ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=" }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "optional": true - }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -30373,6 +30358,21 @@ } } }, + "tldts": { + "version": "6.1.70", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.70.tgz", + "integrity": "sha512-/W1YVgYVJd9ZDjey5NXadNh0mJXkiUMUue9Zebd0vpdo1sU+H4zFFTaJ1RKD4N6KFoHfcXy6l+Vu7bh+bdWCzA==", + "optional": true, + "requires": { + "tldts-core": "^6.1.70" + } + }, + "tldts-core": { + "version": "6.1.70", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.70.tgz", + "integrity": "sha512-RNnIXDB1FD4T9cpQRErEqw6ZpjLlGdMOitdV+0xtbsnwr4YFka1zpc7D4KD+aAn8oSG5JyFrdasZTE04qDE9Yg==", + "optional": true + }, "tlite": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz", @@ -30405,23 +30405,12 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, "tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", "optional": true, "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "dependencies": { - "universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "optional": true - } + "tldts": "^6.1.32" } }, "transform-ast": { @@ -30757,16 +30746,6 @@ } } }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "optional": true, - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -31506,9 +31485,9 @@ } }, "zrender": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.5.0.tgz", - "integrity": "sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", "requires": { "tslib": "2.3.0" }, diff --git a/frontend/package.json b/frontend/package.json index 6a0d7dc12..b50085a54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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.6.0", - "@fortawesome/fontawesome-svg-core": "~6.6.0", - "@fortawesome/free-solid-svg-icons": "~6.6.0", + "@fortawesome/fontawesome-common-types": "~6.7.2", + "@fortawesome/fontawesome-svg-core": "~6.7.2", + "@fortawesome/free-solid-svg-icons": "~6.7.2", "@mempool/mempool.js": "2.3.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0", "@types/qrcode": "~1.5.0", @@ -86,8 +86,7 @@ "browserify": "^17.0.0", "clipboard": "^2.0.11", "domino": "^2.1.6", - "echarts": "~5.5.0", - "lightweight-charts": "~3.8.0", + "echarts": "~5.6.0", "ngx-echarts": "~17.2.0", "ngx-infinite-scroll": "^17.0.0", "qrcode": "1.5.1", @@ -115,7 +114,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.15.0", + "cypress": "^13.17.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.staging.js b/frontend/proxy.conf.staging.js index e24662038..260b222c0 100644 --- a/frontend/proxy.conf.staging.js +++ b/frontend/proxy.conf.staging.js @@ -3,8 +3,10 @@ const fs = require('fs'); let PROXY_CONFIG = require('./proxy.conf'); PROXY_CONFIG.forEach(entry => { - entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space"); - entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space"); + const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space'; + console.log(`e2e tests running against ${hostname}`); + entry.target = entry.target.replace("mempool.space", hostname); + entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space"); }); module.exports = PROXY_CONFIG; diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index cef630984..0fe519a01 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -439,4 +439,39 @@ export const fiatCurrencies = { code: 'ZAR', indexed: true, }, -}; \ No newline at end of file +}; + +export interface Timezone { + offset: string; + name: string; +} + +export const timezones: Timezone[] = [ + { offset: '-12', name: 'Anywhere on Earth (AoE)' }, + { offset: '-11', name: 'Samoa Standard Time (SST)' }, + { offset: '-10', name: 'Hawaii Standard Time (HST)' }, + { offset: '-9', name: 'Alaska Standard Time (AKST)' }, + { offset: '-8', name: 'Pacific Standard Time (PST)' }, + { offset: '-7', name: 'Mountain Standard Time (MST)' }, + { offset: '-6', name: 'Central Standard Time (CST)' }, + { offset: '-5', name: 'Eastern Standard Time (EST)' }, + { offset: '-4', name: 'Atlantic Standard Time (AST)' }, + { offset: '-3', name: 'Argentina Time (ART)' }, + { offset: '-2', name: 'Fernando de Noronha Time (FNT)' }, + { offset: '-1', name: 'Azores Time (AZOT)' }, + { offset: '+0', name: 'Greenwich Mean Time (GMT)' }, + { offset: '+1', name: 'Central European Time (CET)' }, + { offset: '+2', name: 'Eastern European Time (EET)' }, + { offset: '+3', name: 'Moscow Standard Time (MSK)' }, + { offset: '+4', name: 'Armenia Time (AMT)' }, + { offset: '+5', name: 'Pakistan Standard Time (PKT)' }, + { offset: '+6', name: 'Xinjiang Time (XJT)' }, + { offset: '+7', name: 'Indochina Time (ICT)' }, + { offset: '+8', name: 'Hong Kong Time (HKT)' }, + { offset: '+9', name: 'Japan Standard Time (JST)' }, + { offset: '+10', name: 'Australian Eastern Standard Time (AEST)' }, + { offset: '+11', name: 'Norfolk Time (NFT)' }, + { offset: '+12', name: 'New Zealand Standard Time (NZST)' }, + { offset: '+13', name: 'Tonga Time (TOT)' }, + { offset: '+14', name: 'Line Islands Time (LINT)' } +]; \ 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 index df67de65c..de6c71947 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,10 +1,18 @@ -
+
@if (accelerateError) { -
-
-

Sorry, something went wrong!

+ @if (accelerateError.includes('Payment declined')) { +
+
+

{{ accelerateError }}

+
-
+ } @else { +
+
+

Sorry, something went wrong!

+
+
+ }
@@ -357,11 +365,11 @@
-
+
-

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

+ Payment to mempool.space for acceleration of txid {{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}
- @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) { + @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {

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

@@ -378,9 +386,12 @@

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

} @else if (btcpayInvoiceFailed) { -

Failed to load invoice

-
- +
+ + Failed to load invoice + @if (!loadingBtcpayInvoice) { + + }
} @else {

Loading invoice...

@@ -389,13 +400,13 @@
}
- @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) { + @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay || canPayWithCardOnFile) {
-

OR

+

—— OR ——

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

Pay  with

@if (canPayWithCashapp) { @@ -413,6 +424,13 @@
} + @if (canPayWithCardOnFile) { + @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) { } +
+ + {{ estimate?.availablePaymentMethods?.cardOnFile?.card?.brand }} {{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }} +
+ }
}
@@ -435,7 +453,7 @@
- } @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') { + } @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay' || step === 'cardonfile') {
@@ -451,7 +469,7 @@
- @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) { + @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay || step === 'cardonfile' && !loadingCardOnFile) {
@@ -476,14 +494,24 @@
} @else if (step === 'googlepay') {
+ } @else if (step === 'cardonfile') { +
+ + {{ estimate?.availablePaymentMethods?.cardOnFile?.card?.brand }} {{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }} +
} - @if (loadingCashapp || loadingApplePay || loadingGooglePay) { + @if (loadingCashapp || loadingApplePay || loadingGooglePay || loadingCardOnFile) {
Loading payment method...
}
+ @if (isTokenizing > 0) { +
+
+
+ }
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss index ad085ed20..27a7e0e90 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -8,6 +8,13 @@ color: var(--green) } +.accelerate-checkout-inner { + &.input-disabled { + pointer-events: none; + opacity: 0.75; + } +} + .paymentMethod { padding: 10px; background-color: var(--secondary); @@ -146,6 +153,11 @@ .payment-area { background: var(--bg); + margin-top: 0.5rem; + padding: 0.5rem; + @media (max-width: 575px) { + padding-bottom: 1.25rem; + } } .col.pie { @@ -212,4 +224,17 @@ } .apple-pay-button-white-with-line { -apple-pay-button-style: white-outline; +} + +.btcpay-invoice { + display: flex; + height: 292px; + flex-direction: column; + justify-content: center; + align-items: center; + @media (max-width: 575px) { + height: 75px; + flex-direction: row; + gap: 5px; + } } \ 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 index 1a5ace34f..f4f9fbbc2 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -13,7 +13,7 @@ import { EnterpriseService } from '@app/services/enterprise.service'; import { ApiService } from '@app/services/api.service'; import { isDevMode } from '@angular/core'; -export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay'; +export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile'; export type AccelerationEstimate = { hasAccess: boolean; @@ -26,7 +26,7 @@ export type AccelerationEstimate = { mempoolBaseFee: number; vsizeFee: number; pools: number[]; - availablePaymentMethods: Record; + availablePaymentMethods: Record; unavailable?: boolean; options: { // recommended bid options fee: number; // recommended userBid in sats @@ -49,7 +49,7 @@ 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'; +type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'cardonfile' | 'processing' | 'paid' | 'success'; @Component({ selector: 'app-accelerate-checkout', @@ -62,9 +62,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { @Input() miningStats: MiningStats; @Input() eta: ETA; @Input() scrollEvent: boolean; - @Input() cashappEnabled: boolean = true; @Input() applePayEnabled: boolean = false; @Input() googlePayEnabled: boolean = true; + @Input() cardOnFileEnabled: boolean = true; @Input() advancedEnabled: boolean = false; @Input() forceMobile: boolean = false; @Input() showDetails: boolean = false; @@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { calculating = true; processing = false; + isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked + isTokenizing = 0; // reference counter, 0 = false, >0 = true selectedOption: 'wait' | 'accel'; cantPayReason = ''; quoteError = ''; // error fetching estimate or initial data @@ -115,6 +117,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { loadingCashapp = false; loadingApplePay = false; loadingGooglePay = false; + loadingCardOnFile = false; payments: any; cashAppPay: any; applePay: any; @@ -154,7 +157,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerateError = null; this.timePaid = 0; this.btcpayInvoiceFailed = false; - this.moveToStep('summary'); + this.moveToStep('summary', true); } else { this.auth = auth; } @@ -163,11 +166,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('cash_request_id')) { // Redirected from cashapp - this.moveToStep('processing'); + this.moveToStep('processing', true); this.insertSquare(); this.setupSquare(); } else { - this.moveToStep('summary'); + this.moveToStep('summary', true); } this.conversionsSubscription = this.stateService.conversions$.subscribe( @@ -192,14 +195,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } if (changes.accelerating && this.accelerating) { if (this.step === 'processing' || this.step === 'paid') { - this.moveToStep('success'); + this.moveToStep('success', true); } else { // Edge case where the transaction gets accelerated by someone else or on another session this.closeModal(); } } } - moveToStep(step: CheckoutStep): void { + moveToStep(step: CheckoutStep, force: boolean = false): void { + if (this.isCheckoutLocked > 0 && !force) { + return; + } this.processing = false; this._step = step; if (this.timeoutTimer) { @@ -214,10 +220,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } if (this._step === 'checkout' && this.canPayWithBitcoin) { this.btcpayInvoiceFailed = false; - this.loadingBtcpayInvoice = true; this.invoice = null; this.requestBTCPayInvoice(); - } else if (this._step === 'cashapp' && this.cashappEnabled) { + } else if (this._step === 'cashapp') { this.loadingCashapp = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); @@ -229,6 +234,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingGooglePay = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'cardonfile' && this.cardOnFileEnabled) { + this.loadingCardOnFile = true; + this.setupSquare(); + this.scrollToElementWithTimeout('confirm-title', 'center', 100); } else if (this._step === 'paid') { this.timePaid = Date.now(); this.timeoutTimer = setTimeout(() => { @@ -242,7 +251,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { closeModal(): void { this.completed.emit(true); - this.moveToStep('summary'); + this.moveToStep('summary', true); } /** @@ -323,7 +332,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } if (this.step === 'checkout' && this.canPayWithBitcoin && !this.loadingBtcpayInvoice) { - this.loadingBtcpayInvoice = true; this.requestBTCPayInvoice(); } @@ -393,7 +401,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.audioService.playSound('ascend-chime-cartoon'); this.showSuccess = true; this.estimateSubscription.unsubscribe(); - this.moveToStep('paid'); + this.moveToStep('paid', true); }, error: (response) => { this.processing = false; @@ -449,6 +457,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { await this.requestApplePayPayment(); } else if (this._step === 'googlepay') { await this.requestGooglePayPayment(); + } else if (this._step === 'cardonfile') { + this.loadingCardOnFile = false; } }, error: () => { @@ -503,56 +513,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } this.loadingApplePay = false; applePayButton.addEventListener('click', async event => { + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } 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'; - this.processing = false; - 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)}`, - costUSD - ).subscribe({ - next: () => { + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.isCheckoutLocked++; + this.isTokenizing++; + 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'; this.processing = false; - 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.processing = false; - 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); - } + return; } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isTokenizing++; + this.isCheckoutLocked++; + this.servicesApiService.accelerateWithApplePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD + ).subscribe({ + next: () => { + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.applePay) { + this.applePay.destroy(); + } + setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; + // 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')}`, ``)); + }, 10000); + } + } + }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); } - throw new Error(errorMessage); + } finally { + // always unlock the checkout once we're finished + this.isTokenizing--; + this.isCheckoutLocked--; } }); } catch (e) { @@ -602,62 +631,193 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingGooglePay = false; document.getElementById('google-pay-button').addEventListener('click', async event => { + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } 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'; - this.processing = false; - 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)}`, - costUSD - ).subscribe({ - next: () => { + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.isCheckoutLocked++; + this.isTokenizing++; + 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'; this.processing = false; - 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.processing = false; - 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); - } + return; } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; + const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2)); + if (!verificationToken || !verificationToken.token) { + console.error(`SCA verification failed`); + this.accelerateError = 'SCA Verification Failed. Payment Declined.'; + this.processing = false; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isCheckoutLocked++; + this.isTokenizing++; + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + verificationToken.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD, + verificationToken.userChallenged + ).subscribe({ + next: () => { + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.googlePay) { + this.googlePay.destroy(); + } + setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + this.isTokenizing--; + this.isCheckoutLocked--; + 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')}`, ``)); + }, 10000); + } + } + }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); } - throw new Error(errorMessage); + } finally { + // always unlock the checkout once we're finished + this.isTokenizing--; + this.isCheckoutLocked--; } }); } ); } + /** + * Card On File + */ + async requestCardOnFilePayment(): Promise { + if (this.processing) { + return; + } + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.processing = true; + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + + const costUSD = this.cost / 100_000_000 * conversions.USD; + if (this.isCheckoutLocked > 0) { + return; + } + const cardOnFile = this.estimate?.availablePaymentMethods?.cardOnFile; + if (!cardOnFile?.card) { + this.accelerateError = 'card_on_file_not_found'; + return; + } + this.loadingCardOnFile = false; + + try { + this.isCheckoutLocked += 2; + this.isTokenizing += 2; + + const nameParts = cardOnFile.card.name.split(' '); + const assumedGivenName = nameParts[0]; + const assumedFamilyName = nameParts.length > 1 ? nameParts[1] : undefined; + const verificationDetails = { + card: { + billing: { + givenName: assumedGivenName, + familyName: assumedFamilyName, + addressLines: [cardOnFile.card.billing.addressLine1 ?? ''], + city: cardOnFile.card.billing.locality ?? '', + state: cardOnFile.card.billing.administrativeDistrictLevel1 ?? '', + countyCode: cardOnFile.card.billing.country, + } + } + }; + const verificationToken = await this.$verifyBuyer(this.payments, cardOnFile.card.card_id, verificationDetails, costUSD.toFixed(2)); + if (!verificationToken || !verificationToken.token) { + console.error(`SCA verification failed`); + this.accelerateError = 'SCA Verification Failed. Payment Declined.'; + this.processing = false; + return; + } + + this.servicesApiService.accelerateWithCardOnFile$( + this.tx.txid, + cardOnFile.card.card_id, + verificationToken.token, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD, + verificationToken.userChallenged + ).subscribe({ + next: () => { + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + setTimeout(() => { + this.isCheckoutLocked--; + this.isTokenizing--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + this.isCheckoutLocked--; + this.isTokenizing--; + 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); + } + } + }); + + } catch (e) { + console.log(e); + this.isCheckoutLocked--; + this.isTokenizing--; + this.processing = false; + this.accelerateError = e.message; + + } finally { + // always unlock the checkout once we're finished + this.isCheckoutLocked--; + this.isTokenizing--; + } + } + ); + } + /** * CASHAPP */ @@ -718,7 +878,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.cashAppPay.destroy(); } setTimeout(() => { - this.moveToStep('paid'); + this.moveToStep('paid', true); 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')}`, '')); @@ -733,7 +893,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { // 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); + }, 10000); } } }); @@ -743,20 +903,49 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ); } + /** + * https://developer.squareup.com/docs/sca-overview + */ + async $verifyBuyer(payments, token, details, amount): Promise<{token: string, userChallenged: boolean}> { + const verificationDetails = { + amount: amount, + currencyCode: 'USD', + intent: 'CHARGE', + billingContact: { + givenName: details.card?.billing?.givenName, + familyName: details.card?.billing?.familyName, + phone: details.card?.billing?.phone, + addressLines: details.card?.billing?.addressLines, + city: details.card?.billing?.city, + state: details.card?.billing?.state, + countryCode: details.card?.billing?.countryCode, + }, + }; + + const verificationResults = await payments.verifyBuyer( + token, + verificationDetails, + ); + return verificationResults; + } + /** * BTCPay */ async requestBTCPayInvoice(): Promise { + this.loadingBtcpayInvoice = true; this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe( switchMap(response => { return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId); }), catchError(error => { console.log(error); + this.loadingBtcpayInvoice = false; this.btcpayInvoiceFailed = true; return of(null); }) ).subscribe((invoice) => { + this.loadingBtcpayInvoice = false; this.invoice = invoice; this.cd.markForCheck(); }); @@ -766,7 +955,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); this.estimateSubscription.unsubscribe(); - this.moveToStep('paid'); + this.moveToStep('paid', true); } isLoggedIn(): boolean { @@ -793,9 +982,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } get couldPayWithCashapp(): boolean { - if (!this.cashappEnabled) { - return false; - } return !!this.estimate?.availablePaymentMethods?.cashapp; } @@ -830,7 +1016,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } get canPayWithCashapp(): boolean { - if (!this.cashappEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) { + if (!this.conversions || (!this.isProdDomain && !isDevMode())) { return false; } @@ -877,6 +1063,22 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return false; } + get canPayWithCardOnFile(): boolean { + if (!this.cardOnFileEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) { + return false; + } + + const paymentMethod = this.estimate?.availablePaymentMethods?.cardOnFile; + 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; 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 6a99edbf1..05602d577 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 @@ -46,6 +46,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest aggregatedHistory$: Observable; statsSubscription: Subscription; + aggregatedHistorySubscription: Subscription; + fragmentSubscription: Subscription; isLoading = true; formatNumber = formatNumber; timespan = ''; @@ -79,8 +81,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest } this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); - - this.route.fragment.subscribe((fragment) => { + + this.fragmentSubscription = this.route.fragment.subscribe((fragment) => { if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) { this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); } @@ -113,7 +115,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest share(), ); - this.aggregatedHistory$.subscribe(); + this.aggregatedHistorySubscription = this.aggregatedHistory$.subscribe(); } ngOnChanges(changes: SimpleChanges): void { @@ -335,8 +337,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest } ngOnDestroy(): void { - if (this.statsSubscription) { - this.statsSubscription.unsubscribe(); - } + this.aggregatedHistorySubscription?.unsubscribe(); + this.fragmentSubscription?.unsubscribe(); + this.statsSubscription?.unsubscribe(); } } 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 225bf1955..179d576e5 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 @@ -14,7 +14,7 @@ Requested - Bid Boost + Bid Boost Block Pool Status @@ -64,7 +64,8 @@ Pending Completed ⌛ Mined ⌛ - Canceled ⌛ + Canceled ⌛ + Failed ⌛ 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 db9345b18..2bbfd5e34 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -478,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } extendSummary(summary) { - let extendedSummary = summary.slice(); + const 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; + let maxTime = Date.now() / 1000; + + const 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 (extendedSummary[i].time > maxTime) { + extendedSummary[i].time = maxTime - 30; + } + maxTime = extendedSummary[i].time; + const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour); if (hours > 1) { for (let j = 1; j < hours; j++) { - let newTime = extendedSummary[i].time + oneHour * j; + const newTime = extendedSummary[i].time - oneHour * j; extendedSummary.splice(i + j, 0, { time: newTime, value: 0 }); } i += hours - 1; } } - return extendedSummary.reverse(); + return extendedSummary; } } diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index 365c23972..01f887c58 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -41,7 +41,7 @@ export class AppComponent implements OnInit { @HostListener('document:keydown', ['$event']) handleKeyboardEvents(event: KeyboardEvent) { - if (event.target instanceof HTMLInputElement) { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } // prevent arrow key horizontal scrolling diff --git a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html index 790f046f7..932586c6d 100644 --- a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html +++ b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.html @@ -10,6 +10,10 @@ } +
+ +
+
diff --git a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts index f931f2c31..8bb20947c 100644 --- a/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts +++ b/frontend/src/app/components/bitcoin-invoice/bitcoin-invoice.component.ts @@ -1,9 +1,8 @@ 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 { Subscription, of, catchError } from 'rxjs'; +import { retry, tap } from 'rxjs/operators'; import { ServicesApiServices } from '@app/services/services-api.service'; @Component({ @@ -18,30 +17,17 @@ export class BitcoinInvoiceComponent implements OnInit, OnChanges, OnDestroy { @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 + paymentErrorMessage: string = ''; constructor( private formBuilder: FormBuilder, private apiService: ServicesApiServices, - private sanitizer: DomSanitizer, - private activatedRoute: ActivatedRoute + private sanitizer: DomSanitizer ) { } 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(); } @@ -72,15 +58,39 @@ export class BitcoinInvoiceComponent implements OnInit, OnChanges, OnDestroy { } else { this.paymentStatus = 4; } + + this.monitorPendingInvoice(); + } + + monitorPendingInvoice(): void { + if (!this.invoice) { + return; + } + if (this.paymentStatusSubscription) { + this.paymentStatusSubscription.unsubscribe(); + } 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(); - }); + tap(result => { + if (result.status === 204) { // Manually trigger an error in that case so we can retry + throw result; + } else if (result.status === 200) { // Invoice settled + this.paymentStatus = 3; + this.completed.emit(); + } + }), + catchError(err => { + if (err.status === 204 || err.status === 504) { + throw err; // Will trigger the retry + } else if (err.status === 400) { + this.paymentErrorMessage = 'Invoice has expired'; + } else if (err.status === 404) { + this.paymentErrorMessage = 'Invoice is no longer valid'; + } + this.paymentStatus = -1; + return of(null); + }), + retry({ delay: 1000 }), + ).subscribe(); } get availableMethods(): string[] { diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index d59e38c13..419a51995 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -172,13 +172,19 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On ngOnDestroy(): void { if (this.animationFrameRequest) { cancelAnimationFrame(this.animationFrameRequest); - clearTimeout(this.animationHeartBeat); } + clearTimeout(this.animationHeartBeat); if (this.canvas) { this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost); this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored); - this.themeChangedSubscription?.unsubscribe(); } + if (this.scene) { + this.scene.destroy(); + } + this.vertexArray.destroy(); + this.vertexArray = null; + this.themeChangedSubscription?.unsubscribe(); + this.searchSubscription?.unsubscribe(); } clear(direction): void { @@ -447,7 +453,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.applyQueuedUpdates(); // skip re-render if there's no change to the scene - if (this.scene && this.gl) { + if (this.scene && this.gl && this.vertexArray) { /* SET UP SHADER UNIFORMS */ // screen dimensions this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); @@ -489,9 +495,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) { this.doRun(); } else { - if (this.animationHeartBeat) { - clearTimeout(this.animationHeartBeat); - } + clearTimeout(this.animationHeartBeat); this.animationHeartBeat = window.setTimeout(() => { this.start(); }, 1000); diff --git a/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts b/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts index 42439ef8d..8f9978d13 100644 --- a/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts +++ b/frontend/src/app/components/block-overview-graph/fast-vertex-array.ts @@ -19,6 +19,7 @@ export class FastVertexArray { freeSlots: number[]; lastSlot: number; dirty = false; + destroyed = false; constructor(length, stride) { this.length = length; @@ -32,6 +33,9 @@ export class FastVertexArray { } insert(sprite: TxSprite): number { + if (this.destroyed) { + return; + } this.count++; let position; @@ -45,11 +49,14 @@ export class FastVertexArray { } } this.sprites[position] = sprite; - return position; this.dirty = true; + return position; } remove(index: number): void { + if (this.destroyed) { + return; + } this.count--; this.clearData(index); this.freeSlots.push(index); @@ -61,20 +68,26 @@ export class FastVertexArray { } setData(index: number, dataChunk: number[]): void { + if (this.destroyed) { + return; + } this.data.set(dataChunk, (index * this.stride)); this.dirty = true; } - clearData(index: number): void { + private clearData(index: number): void { this.data.fill(0, (index * this.stride), ((index + 1) * this.stride)); this.dirty = true; } getData(index: number): Float32Array { + if (this.destroyed) { + return; + } return this.data.subarray(index, this.stride); } - expand(): void { + private expand(): void { this.length *= 2; const newData = new Float32Array(this.length * this.stride); newData.set(this.data); @@ -82,7 +95,7 @@ export class FastVertexArray { this.dirty = true; } - compact(): void { + private compact(): void { // New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512) const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count)))); if (newLength !== this.length) { @@ -110,4 +123,13 @@ export class FastVertexArray { getVertexData(): Float32Array { return this.data; } + + destroy(): void { + this.data = null; + this.sprites = null; + this.freeSlots = null; + this.lastSlot = 0; + this.dirty = false; + this.destroyed = true; + } } diff --git a/frontend/src/app/components/block-view/block-view.component.ts b/frontend/src/app/components/block-view/block-view.component.ts index b5d5256ee..19a18383e 100644 --- a/frontend/src/app/components/block-view/block-view.component.ts +++ b/frontend/src/app/components/block-view/block-view.component.ts @@ -116,7 +116,7 @@ export class BlockViewComponent implements OnInit, OnDestroy { this.isLoadingBlock = false; this.isLoadingOverview = true; }), - shareReplay(1) + shareReplay({ bufferSize: 1, refCount: true }) ); this.overviewSubscription = block$.pipe( @@ -176,5 +176,8 @@ export class BlockViewComponent implements OnInit, OnDestroy { if (this.queryParamsSubscription) { this.queryParamsSubscription.unsubscribe(); } + if (this.blockGraph) { + this.blockGraph.destroy(); + } } } diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts index b2fc3fb6f..42a47f3c4 100644 --- a/frontend/src/app/components/block/block-preview.component.ts +++ b/frontend/src/app/components/block/block-preview.component.ts @@ -117,7 +117,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { this.openGraphService.waitOver('block-data-' + this.rawId); }), throttleTime(50, asyncScheduler, { leading: true, trailing: true }), - shareReplay(1) + shareReplay({ bufferSize: 1, refCount: true }) ); this.overviewSubscription = block$.pipe( diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index dab3c00fa..ddcf023ed 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -1,8 +1,8 @@ import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core'; import { Location } from '@angular/common'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router'; import { ElectrsApiService } from '@app/services/electrs-api.service'; -import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators'; +import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter, take } from 'rxjs/operators'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs'; import { StateService } from '@app/services/state.service'; import { SeoService } from '@app/services/seo.service'; @@ -68,6 +68,7 @@ export class BlockComponent implements OnInit, OnDestroy { paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; numUnexpected: number = 0; mode: 'projected' | 'actual' = 'projected'; + currentQueryParams: Params; overviewSubscription: Subscription; accelerationsSubscription: Subscription; @@ -80,8 +81,8 @@ export class BlockComponent implements OnInit, OnDestroy { timeLtr: boolean; childChangeSubscription: Subscription; auditPrefSubscription: Subscription; + isAuditEnabledSubscription: Subscription; oobSubscription: Subscription; - priceSubscription: Subscription; blockConversion: Price; @@ -118,7 +119,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.setAuditAvailable(this.auditSupported); if (this.auditSupported) { - this.isAuditEnabledFromParam().subscribe(auditParam => { + this.isAuditEnabledSubscription = this.isAuditEnabledFromParam().subscribe(auditParam => { if (this.auditParamEnabled) { this.auditModeEnabled = auditParam; } else { @@ -281,7 +282,7 @@ export class BlockComponent implements OnInit, OnDestroy { } }), throttleTime(300, asyncScheduler, { leading: true, trailing: true }), - shareReplay(1) + shareReplay({ bufferSize: 1, refCount: true }) ); this.overviewSubscription = this.block$.pipe( @@ -363,6 +364,7 @@ export class BlockComponent implements OnInit, OnDestroy { .subscribe((network) => this.network = network); this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { + this.currentQueryParams = params; if (params.showDetails === 'true') { this.showDetails = true; } else { @@ -414,6 +416,7 @@ export class BlockComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.stateService.markBlock$.next({}); this.overviewSubscription?.unsubscribe(); + this.accelerationsSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe(); this.blocksSubscription?.unsubscribe(); this.cacheBlocksSubscription?.unsubscribe(); @@ -421,8 +424,16 @@ export class BlockComponent implements OnInit, OnDestroy { this.queryParamsSubscription?.unsubscribe(); this.timeLtrSubscription?.unsubscribe(); this.childChangeSubscription?.unsubscribe(); - this.priceSubscription?.unsubscribe(); + this.auditPrefSubscription?.unsubscribe(); + this.isAuditEnabledSubscription?.unsubscribe(); this.oobSubscription?.unsubscribe(); + this.priceSubscription?.unsubscribe(); + this.blockGraphProjected.forEach(graph => { + graph.destroy(); + }); + this.blockGraphActual.forEach(graph => { + graph.destroy(); + }); } // TODO - Refactor this.fees/this.reward for liquid because it is not @@ -733,19 +744,18 @@ export class BlockComponent implements OnInit, OnDestroy { toggleAuditMode(): void { this.stateService.hideAudit.next(this.auditModeEnabled); - this.route.queryParams.subscribe(params => { - const queryParams = { ...params }; - delete queryParams['audit']; + const queryParams = { ...this.currentQueryParams }; + delete queryParams['audit']; - let newUrl = this.router.url.split('?')[0]; - const queryString = new URLSearchParams(queryParams).toString(); - if (queryString) { - newUrl += '?' + queryString; - } - - this.location.replaceState(newUrl); - }); + let newUrl = this.router.url.split('?')[0]; + const queryString = new URLSearchParams(queryParams).toString(); + if (queryString) { + newUrl += '?' + queryString; + } + this.location.replaceState(newUrl); + // avoid duplicate subscriptions + this.auditPrefSubscription?.unsubscribe(); this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => { this.auditModeEnabled = !hide; this.showAudit = this.auditAvailable && this.auditModeEnabled; @@ -762,7 +772,7 @@ export class BlockComponent implements OnInit, OnDestroy { return this.route.queryParams.pipe( map(params => { this.auditParamEnabled = 'audit' in params; - + return this.auditParamEnabled ? !(params['audit'] === 'false') : true; }) ); diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.html b/frontend/src/app/components/blocks-list/blocks-list.component.html index 807d429bf..622f56f69 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -49,7 +49,7 @@
- ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + diff --git a/frontend/src/app/components/eight-blocks/eight-blocks.component.ts b/frontend/src/app/components/eight-blocks/eight-blocks.component.ts index 8ca8437ac..0e0861382 100644 --- a/frontend/src/app/components/eight-blocks/eight-blocks.component.ts +++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.ts @@ -162,6 +162,9 @@ export class EightBlocksComponent implements OnInit, OnDestroy { this.cacheBlocksSubscription?.unsubscribe(); this.networkChangedSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe(); + this.blockGraphs.forEach(graph => { + graph.destroy(); + }); } shiftTestBlocks(): void { diff --git a/frontend/src/app/components/faucet/faucet.component.html b/frontend/src/app/components/faucet/faucet.component.html index 3165ae9a7..dbe0f25ea 100644 --- a/frontend/src/app/components/faucet/faucet.component.html +++ b/frontend/src/app/components/faucet/faucet.component.html @@ -19,12 +19,10 @@ } @else if (!user) {
-
- To use the faucet, please  - login -  or +
+ To use the faucet, please
- +
} @else if (user && user.status === 'pending' && !user.email && user.snsId) { @@ -36,18 +34,18 @@
} @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 + Your account does not allow you to access the faucet
} diff --git a/frontend/src/app/components/faucet/faucet.component.ts b/frontend/src/app/components/faucet/faucet.component.ts index 33d9a849e..6bf55ebac 100644 --- a/frontend/src/app/components/faucet/faucet.component.ts +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -24,7 +24,7 @@ export class FaucetComponent implements OnInit, OnDestroy { 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'; + code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon' | 'faucet_not_available_no_utxo'; } | null = null; faucetForm: FormGroup; diff --git a/frontend/src/app/components/github-login.component/github-login.component.html b/frontend/src/app/components/github-login.component/github-login.component.html new file mode 100644 index 000000000..e57019a26 --- /dev/null +++ b/frontend/src/app/components/github-login.component/github-login.component.html @@ -0,0 +1,6 @@ + + {{ buttonString }} + + + + \ No newline at end of file diff --git a/frontend/src/app/components/github-login.component/github-login.component.ts b/frontend/src/app/components/github-login.component/github-login.component.ts new file mode 100644 index 000000000..52f2584b9 --- /dev/null +++ b/frontend/src/app/components/github-login.component/github-login.component.ts @@ -0,0 +1,25 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +@Component({ + selector: 'app-github-login', + templateUrl: './github-login.component.html', +}) +export class GithubLogin { + @Input() width: string | null = null; + @Input() customClass: string | null = null; + @Input() buttonString: string= 'unset'; + @Input() redirectTo: string | null = null; + @Output() clicked = new EventEmitter(); + @Input() disabled: boolean = false; + + constructor() {} + + githubLogin() { + this.clicked.emit(true); + if (this.redirectTo) { + location.replace(`/api/v1/services/auth/login/github?redirectTo=${encodeURIComponent(this.redirectTo)}`); + } else { + location.replace(`/api/v1/services/auth/login/github?redirectTo=${location.href}`); + } + return false; + } +} diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html index 45118e804..ba2d14adb 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html @@ -56,8 +56,7 @@ - ‎{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} -
()
+ {{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} blocks diff --git a/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.html b/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.html index b21d83b4e..97c1d96cd 100644 --- a/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.html @@ -53,8 +53,7 @@
- ‎{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} -
()
+ diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index fca8b279c..a46be2733 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -120,6 +120,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang } ngOnDestroy(): void { + this.blockGraph?.destroy(); this.blockSub.unsubscribe(); this.timeLtrSubscription.unsubscribe(); this.websocketService.stopTrackMempoolBlock(); diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index b74ecdf81..35bad3af7 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -10,7 +10,7 @@

{{ poolStats.pool.name }}

-
+
@@ -173,7 +173,125 @@
+ + +

Next block

+
+
+
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
HeightExpectedRewardTimestamp
+ {{ job.height }} + + + + + ~ + + + + +
+
+ + + + + + + + + + + + + + + + + +
Coinbase tagCleanPrevhashJob Received
+ {{ job.scriptsig | hex2ascii }} + + @if (job.cleanJobs) { + + } @else { + + } + + + + + + +
+
+ + + + + + + + + @for (branch of job.merkleBranches; track $index) { + + } + @for (_ of [].constructor(Math.max(0, 12 - job.merkleBranches.length)); track $index) { + + } + + +
+ + Merkle Branches +   + + +
+ @if ($index === 0 && branch) { + + + + } +
+
+
+
+
+
+ +

Blocks

@@ -194,7 +312,7 @@ {{ block.height }} } @else { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 8c2d9de01..d49ca7049 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -24,6 +24,7 @@ [height]="tx?.status?.block_height" [replaced]="replaced" [removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed" + [cached]="isCached" > @@ -138,7 +139,6 @@ transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed), + filter((transactionTimes) => transactionTimes?.[0] > 0 || this.tx.status?.confirmed), take(1), )), ) @@ -528,9 +527,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.miningStats = stats; }); } - if (txPosition.position?.block > 0 && this.tx.weight < 4000) { - this.cashappEligible = true; - } if (!this.gotInitialPosition && txPosition.position?.block === 0 && txPosition.position?.vsize < 750_000) { this.accelerationFlowCompleted = true; } @@ -1036,7 +1032,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.showAccelerationDetails = false; this.accelerationFlowCompleted = false; this.accelerationInfo = null; - this.cashappEligible = false; this.txInBlockIndex = null; this.mempoolPosition = null; this.pool = null; diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 5ad1c798c..6f1d76538 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -6,7 +6,7 @@
- ‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + @@ -81,7 +81,7 @@
-
- - + diff --git a/frontend/src/app/lightning/justice-list/justice-list.component.html b/frontend/src/app/lightning/justice-list/justice-list.component.html index 482ac9646..9f341b0c8 100644 --- a/frontend/src/app/lightning/justice-list/justice-list.component.html +++ b/frontend/src/app/lightning/justice-list/justice-list.component.html @@ -19,7 +19,7 @@
- ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + diff --git a/frontend/src/app/components/pool/pool.component.scss b/frontend/src/app/components/pool/pool.component.scss index 5c2fedd26..26e6008b6 100644 --- a/frontend/src/app/components/pool/pool.component.scss +++ b/frontend/src/app/components/pool/pool.component.scss @@ -49,111 +49,110 @@ div.scrollable { max-height: 75px; } -.box { - padding-bottom: 5px; +.pool-details { @media (min-width: 767.98px) { min-height: 187px; } -} -.label { - width: 25%; - @media (min-width: 767.98px) { - vertical-align: middle; + .label { + width: 25%; + @media (min-width: 767.98px) { + vertical-align: middle; + } + @media (max-width: 767.98px) { + font-weight: bold; + } } - @media (max-width: 767.98px) { - font-weight: bold; + .label.addresses { + vertical-align: top; + padding-top: 25px; + } + .addresses-data { + vertical-align: top; + font-family: monospace; + font-size: 14px; } -} -.label.addresses { - vertical-align: top; - padding-top: 25px; -} -.addresses-data { - vertical-align: top; - font-family: monospace; - font-size: 14px; -} -.data { - text-align: right; - padding-left: 5%; - @media (max-width: 992px) { - text-align: left; - padding-left: 12px; - } - @media (max-width: 450px) { + .data { text-align: right; + padding-left: 5%; + @media (max-width: 992px) { + text-align: left; + padding-left: 12px; + } + @media (max-width: 450px) { + text-align: right; + } } -} -.progress { - background-color: var(--secondary); -} + .progress { + background-color: var(--secondary); + } -.coinbase { - width: 20%; - @media (max-width: 875px) { - display: none; - } -} - -.height { - width: 10%; -} - -.timestamp { - @media (max-width: 875px) { - padding-left: 50px; - } - @media (max-width: 685px) { - display: none; - } -} - -.mined { - width: 13%; - @media (max-width: 1100px) { - display: none; - } -} - -.txs { - padding-right: 40px; - @media (max-width: 1100px) { - padding-right: 10px; - } - @media (max-width: 875px) { - padding-right: 20px; - } - @media (max-width: 567px) { - padding-right: 10px; - } -} - -.size { - width: 12%; - @media (max-width: 1000px) { - width: 15%; - } - @media (max-width: 875px) { + .coinbase { width: 20%; + @media (max-width: 875px) { + display: none; + } } - @media (max-width: 650px) { - width: 20%; - } - @media (max-width: 450px) { - display: none; - } -} -.scriptmessage { - overflow: hidden; - display: inline-block; - text-overflow: ellipsis; - vertical-align: middle; - width: auto; - text-align: left; + .height { + width: 10%; + } + + .timestamp { + @media (max-width: 875px) { + padding-left: 50px; + } + @media (max-width: 685px) { + display: none; + } + } + + .mined { + width: 13%; + @media (max-width: 1100px) { + display: none; + } + } + + .txs { + padding-right: 40px; + @media (max-width: 1100px) { + padding-right: 10px; + } + @media (max-width: 875px) { + padding-right: 20px; + } + @media (max-width: 567px) { + padding-right: 10px; + } + } + + .size { + width: 12%; + @media (max-width: 1000px) { + width: 15%; + } + @media (max-width: 875px) { + width: 20%; + } + @media (max-width: 650px) { + width: 20%; + } + @media (max-width: 450px) { + display: none; + } + } + + .scriptmessage { + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; + vertical-align: middle; + width: auto; + text-align: left; + } } .skeleton-loader { @@ -214,4 +213,69 @@ div.scrollable { .taller-row { height: 75px; +} + +.stratum-table { + width: 100%; + + .merkle { + width: 100px; + text-align: center; + } + + .empty-branch { + outline: solid 1px white; + outline-offset: -1px; + + &::after { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + background: linear-gradient(to top left, transparent, transparent 48%, white 49%, white 51%, transparent 52%, transparent); + } + } + + td { + position: relative; + height: 2em; + + .cell-link { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: inherit; + text-decoration: none; + display: flex; + justify-content: center; + align-items: center; + } + } +} + +.job-table { + td, th { + width: 25%; + max-width: 25%; + min-width: 25%; + overflow: hidden; + text-overflow: ellipsis; + padding: 0.1rem 0.2rem; + } + + @media (max-width: 767.98px) { + .expected, .timestamp, .clean, .job-received { + display: none; + } + } +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + text-decoration: none; + color: inherit; } \ No newline at end of file diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index 1893f0a48..4b4b643a2 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -10,6 +10,9 @@ import { selectPowerOfTen } from '@app/bitcoin.utils'; import { formatNumber } from '@angular/common'; import { SeoService } from '@app/services/seo.service'; import { HttpErrorResponse } from '@angular/common/http'; +import { StratumJob } from '../../interfaces/websocket.interface'; +import { WebsocketService } from '../../services/websocket.service'; +import { MiningService } from '../../services/mining.service'; interface AccelerationTotal { cost: number, @@ -27,12 +30,16 @@ export class PoolComponent implements OnInit { @Input() left: number | string = 75; gfg = true; + stratumEnabled = this.stateService.env.STRATUM_ENABLED; formatNumber = formatNumber; + Math = Math; slugSubscription: Subscription; poolStats$: Observable; blocks$: Observable; oobFees$: Observable; + job$: Observable; + expectedBlockTime$: Observable; isLoading = true; error: HttpErrorResponse | null = null; @@ -53,6 +60,8 @@ export class PoolComponent implements OnInit { private apiService: ApiService, private route: ActivatedRoute, public stateService: StateService, + private websocketService: WebsocketService, + private miningService: MiningService, private seoService: SeoService, ) { this.auditAvailable = this.stateService.env.AUDIT; @@ -62,7 +71,7 @@ export class PoolComponent implements OnInit { this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => { this.isLoading = true; this.blocks = []; - this.chartOptions = {}; + this.chartOptions = {}; this.slug = slug; this.initializeObservables(); }); @@ -129,6 +138,31 @@ export class PoolComponent implements OnInit { }), filter(oob => oob.length === 3 && oob[2].count > 0) ); + + if (this.stratumEnabled) { + this.job$ = combineLatest([ + this.poolStats$.pipe( + tap((poolStats) => { + this.websocketService.startTrackStratum(poolStats.pool.unique_id); + }) + ), + this.stateService.stratumJobs$ + ]).pipe( + map(([poolStats, jobs]) => { + return jobs[poolStats.pool.unique_id]; + }) + ); + + this.expectedBlockTime$ = combineLatest([ + this.miningService.getMiningStats('1w'), + this.poolStats$, + this.stateService.difficultyAdjustment$ + ]).pipe( + map(([miningStats, poolStat, da]) => { + return (da.timeAvg / ((poolStat.estimatedHashrate || 0) / (miningStats.lastEstimatedHashrate * 1_000_000_000_000_000_000))) + Date.now() + da.timeOffset; + }) + ); + } } prepareChartOptions(hashrate, share) { @@ -327,6 +361,10 @@ export class PoolComponent implements OnInit { return block.height; } + reverseHash(hash: string) { + return hash.match(/../g).reverse().join(''); + } + ngOnDestroy(): void { this.slugSubscription.unsubscribe(); } diff --git a/frontend/src/app/components/push-transaction/push-transaction.component.scss b/frontend/src/app/components/push-transaction/push-transaction.component.scss index e69de29bb..ffdd5811b 100644 --- a/frontend/src/app/components/push-transaction/push-transaction.component.scss +++ b/frontend/src/app/components/push-transaction/push-transaction.component.scss @@ -0,0 +1,34 @@ +.accept-results { + td, th { + &.allowed { + width: 10%; + text-align: center; + } + &.txid { + width: 50%; + } + &.rate { + width: 20%; + text-align: right; + white-space: wrap; + } + &.reason { + width: 20%; + text-align: right; + white-space: wrap; + } + } + + @media (max-width: 950px) { + table-layout: auto; + + td, th { + &.allowed { + width: 100px; + } + &.txid { + max-width: 200px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/stratum/stratum-list/stratum-list.component.html b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.html new file mode 100644 index 000000000..24801cf2c --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.html @@ -0,0 +1,55 @@ +
+

Stratum Jobs

+ +
+ +
+ + + + + + + + + + + + @for (row of rows; track row.job.pool) { + + @for (cell of row.merkleCells; track $index) { + + } + + + + + + } + +
+ Merkle Branches + PoolCoinbase TagRewardHeight
+ @if ($index === 0 && cell.hash) { + +
+
+ } @else { +
+ } +
+ @if (pools[row.job.pool]) { + + + {{ pools[row.job.pool].name}} + + } + + {{ row.job.tag }} + + + + {{ row.job.height }} +
+
+
diff --git a/frontend/src/app/components/stratum/stratum-list/stratum-list.component.scss b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.scss new file mode 100644 index 000000000..3d274ef2a --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.scss @@ -0,0 +1,138 @@ +.stratum-table { + width: 100%; +} + +td { + position: relative; + height: 2em; + + &.height, &.reward, &.tag { + padding: 0 5px; + } + + &.tag { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.pool { + padding-left: 5px; + padding-right: 20px; + } + + &.merkle { + width: 100px; + .pipe-segment { + position: absolute; + border-color: white; + box-sizing: content-box; + + &.vertical { + top: 0; + right: 0; + width: 50%; + height: 100%; + border-left: solid 4px; + } + &.horizontal { + bottom: 0; + left: 0; + width: 100%; + height: 50%; + border-top: solid 4px; + } + &.branch-top { + bottom: 0; + right: 0; + width: 100%; + height: 50%; + border-top: solid 4px; + &::after { + content: ""; + position: absolute; + box-sizing: content-box; + top: -4px; + right: 0px; + bottom: 0; + width: 50%; + border-top: solid 4px; + border-left: solid 4px; + border-top-left-radius: 5px; + } + } + &.branch-mid { + bottom: 0; + right: 0px; + width: 50%; + height: 100%; + border-left: solid 4px; + &::after { + content: ""; + position: absolute; + box-sizing: content-box; + top: -4px; + left: -4px; + width: 100%; + height: 50%; + border-bottom: solid 4px; + border-left: solid 4px; + border-bottom-left-radius: 5px; + } + } + &.branch-end { + top: -4px; + right: 0; + width: 50%; + height: 50%; + border-bottom-left-radius: 5px; + border-bottom: solid 4px; + border-left: solid 4px; + } + } + } + + .cell-link { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: inherit; + text-decoration: none; + } +} + +@media (max-width: 800px) { + .stratum-table { + td { + &.tag { + display: none; + } + } + } +} + +@media (max-width: 650px) { + .stratum-table { + td { + &.reward { + display: none; + } + } + } +} + +.badge { + position: relative; + color: #FFF; +} + +.pool-logo { + width: 15px; + height: 15px; + position: relative; + top: -1px; + margin-right: 2px; +} \ No newline at end of file diff --git a/frontend/src/app/components/stratum/stratum-list/stratum-list.component.ts b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.ts new file mode 100644 index 000000000..481447b07 --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.ts @@ -0,0 +1,230 @@ +import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { StateService } from '../../../services/state.service'; +import { WebsocketService } from '../../../services/websocket.service'; +import { map, Observable } from 'rxjs'; +import { StratumJob } from '../../../interfaces/websocket.interface'; +import { MiningService } from '../../../services/mining.service'; +import { SinglePoolStats } from '../../../interfaces/node-api.interface'; + +type MerkleCellType = ' ' | '┬' | '├' | '└' | '│' | '─' | 'leaf'; + + +interface TaggedStratumJob extends StratumJob { + tag: string; + merkleBranchIds: string[]; +} + +interface MerkleCell { + hash: string; + type: MerkleCellType; + job?: TaggedStratumJob; +} + +interface MerkleTree { + hash?: string; + job: string; + size: number; + children?: MerkleTree[]; +} + +interface PoolRow { + job: TaggedStratumJob; + merkleCells: MerkleCell[]; +} + +function parseTag(scriptSig: string): string { + const hex = scriptSig.slice(8).replace(/6d6d.{64}/, ''); + const bytes: number[] = []; + for (let i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substr(i, 2), 16)); + } + // eslint-disable-next-line no-control-regex + const ascii = new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '').replace(/[\x00-\x1F\x7F-\x9F]/g, ''); + if (ascii.includes('/ViaBTC/')) { + return '/ViaBTC/'; + } else if (ascii.includes('SpiderPool/')) { + return 'SpiderPool/'; + } + return (ascii.match(/\/.*\//)?.[0] || ascii).trim(); +} + +function getMerkleBranchIds(merkleBranches: string[], numBranches: number, poolId: number): string[] { + let lastHash = ''; + const ids: string[] = []; + for (let i = 0; i < numBranches; i++) { + if (merkleBranches[i]) { + lastHash = merkleBranches[i]; + ids.push(`${i}-${lastHash}`); + } else { + ids.push(`${i}-${lastHash}-${poolId}`); + } + } + return ids; +} + +@Component({ + selector: 'app-stratum-list', + templateUrl: './stratum-list.component.html', + styleUrls: ['./stratum-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StratumList implements OnInit, OnDestroy { + rows$: Observable; + pools: { [id: number]: SinglePoolStats } = {}; + poolsReady: boolean = false; + + constructor( + private stateService: StateService, + private websocketService: WebsocketService, + private miningService: MiningService, + private cd: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.websocketService.want(['stats', 'blocks', 'mempool-blocks']); + this.miningService.getPools().subscribe(pools => { + this.pools = {}; + for (const pool of pools) { + this.pools[pool.unique_id] = pool; + } + this.poolsReady = true; + this.cd.markForCheck(); + }); + this.rows$ = this.stateService.stratumJobs$.pipe( + map((jobs) => this.processJobs(jobs)), + ); + this.websocketService.startTrackStratum('all'); + } + + processJobs(rawJobs: Record): PoolRow[] { + const numBranches = Math.max(...Object.values(rawJobs).map(job => job.merkleBranches.length)); + const jobs: Record = {}; + for (const [id, job] of Object.entries(rawJobs)) { + jobs[id] = { ...job, tag: parseTag(job.scriptsig), merkleBranchIds: getMerkleBranchIds(job.merkleBranches, numBranches, job.pool) }; + } + if (Object.keys(jobs).length === 0) { + return []; + } + + let trees: MerkleTree[] = Object.keys(jobs).map(job => ({ + job, + size: 1, + })); + + // build tree from bottom up + for (let col = numBranches - 1; col >= 0; col--) { + const groups: Record = {}; + for (const tree of trees) { + const branchId = jobs[tree.job].merkleBranchIds[col]; + if (!groups[branchId]) { + groups[branchId] = []; + } + groups[branchId].push(tree); + } + + trees = Object.values(groups).map(group => ({ + hash: jobs[group[0].job].merkleBranches[col], + job: group[0].job, + children: group, + size: group.reduce((acc, tree) => acc + tree.size, 0), + })); + } + + // initialize grid of cells + const rows: (MerkleCell | null)[][] = []; + for (let i = 0; i < Object.keys(jobs).length; i++) { + const row: (MerkleCell | null)[] = []; + for (let j = 0; j <= numBranches; j++) { + row.push(null); + } + rows.push(row); + } + + // fill in the cells + let colTrees = [trees.sort((a, b) => { + if (a.size !== b.size) { + return b.size - a.size; + } + return a.job.localeCompare(b.job); + })]; + for (let col = 0; col <= numBranches; col++) { + let row = 0; + const nextTrees: MerkleTree[][] = []; + for (let g = 0; g < colTrees.length; g++) { + for (let t = 0; t < colTrees[g].length; t++) { + const tree = colTrees[g][t]; + const isFirstTree = (t === 0); + const isLastTree = (t === colTrees[g].length - 1); + for (let i = 0; i < tree.size; i++) { + const isFirstCell = (i === 0); + const isLeaf = (col === numBranches); + rows[row][col] = { + hash: tree.hash, + job: isLeaf ? jobs[tree.job] : undefined, + type: 'leaf', + }; + if (col > 0) { + rows[row][col - 1].type = getCellType(isFirstCell, isFirstTree, isLastTree); + } + row++; + } + if (tree.children) { + nextTrees.push(tree.children.sort((a, b) => { + if (a.size !== b.size) { + return b.size - a.size; + } + return a.job.localeCompare(b.job); + })); + } + } + } + colTrees = nextTrees; + } + return rows.map(row => ({ + job: row[row.length - 1].job, + merkleCells: row.slice(0, -1), + })); + } + + pipeToClass(type: MerkleCellType): string { + return { + ' ': 'empty', + '┬': 'branch-top', + '├': 'branch-mid', + '└': 'branch-end', + '│': 'vertical', + '─': 'horizontal', + 'leaf': 'leaf' + }[type]; + } + + reverseHash(hash: string) { + return hash.match(/../g).reverse().join(''); + } + + ngOnDestroy(): void { + this.websocketService.stopTrackStratum(); + } +} + +function getCellType(isFirstCell, isFirstTree, isLastTree): MerkleCellType { + if (isFirstCell) { + if (isFirstTree) { + if (isLastTree) { + return '─'; + } else { + return '┬'; + } + } else if (isLastTree) { + return '└'; + } else { + return '├'; + } + } else { + if (isLastTree) { + return ' '; + } else { + return '│'; + } + } +} diff --git a/frontend/src/app/components/timezone-selector/timezone-selector.component.html b/frontend/src/app/components/timezone-selector/timezone-selector.component.html new file mode 100644 index 000000000..bd959ac3d --- /dev/null +++ b/frontend/src/app/components/timezone-selector/timezone-selector.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/frontend/src/app/components/timezone-selector/timezone-selector.component.scss b/frontend/src/app/components/timezone-selector/timezone-selector.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/components/timezone-selector/timezone-selector.component.ts b/frontend/src/app/components/timezone-selector/timezone-selector.component.ts new file mode 100644 index 000000000..44c04354e --- /dev/null +++ b/frontend/src/app/components/timezone-selector/timezone-selector.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { StorageService } from '@app/services/storage.service'; +import { StateService } from '@app/services/state.service'; +import { timezones } from '@app/app.constants'; + + +@Component({ + selector: 'app-timezone-selector', + templateUrl: './timezone-selector.component.html', + styleUrls: ['./timezone-selector.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TimezoneSelectorComponent implements OnInit { + timezoneForm: UntypedFormGroup; + timezones = timezones; + localTimezoneOffset: string = ''; + localTimezoneName: string; + + constructor( + private formBuilder: UntypedFormBuilder, + private stateService: StateService, + private storageService: StorageService, + ) { } + + ngOnInit() { + this.setLocalTimezone(); + this.timezoneForm = this.formBuilder.group({ + mode: ['local'], + }); + this.stateService.timezone$.subscribe((mode) => { + this.timezoneForm.get('mode')?.setValue(mode); + }); + } + + changeMode() { + const newMode = this.timezoneForm.get('mode')?.value; + this.storageService.setValue('timezone-preference', newMode); + this.stateService.timezone$.next(newMode); + } + + setLocalTimezone() { + const offset = new Date().getTimezoneOffset(); + const sign = offset <= 0 ? "+" : "-"; + const absOffset = Math.abs(offset); + const hours = String(Math.floor(absOffset / 60)); + const minutes = String(absOffset % 60).padStart(2, '0'); + if (minutes === '00') { + this.localTimezoneOffset = `${sign}${hours}`; + } else { + this.localTimezoneOffset = `${sign}${hours.padStart(2, '0')}:${minutes}`; + } + + const timezone = this.timezones.find(tz => tz.offset === this.localTimezoneOffset); + this.timezones = this.timezones.filter(tz => tz.offset !== this.localTimezoneOffset && tz.offset !== '+0'); + this.localTimezoneName = timezone ? timezone.name : ''; + } +} diff --git a/frontend/src/app/components/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html index 2d9bd4982..e07898da3 100644 --- a/frontend/src/app/components/tracker/tracker.component.html +++ b/frontend/src/app/components/tracker/tracker.component.html @@ -88,7 +88,7 @@
Confirmed at
- ‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} +
()
@@ -124,7 +124,6 @@ 0 && this.tx.weight < 4000; - } - get showAccelerationSummary(): boolean { return ( this.tx diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index 0bfcb494e..c5609882c 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -61,10 +61,7 @@
Timestamp - ‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} -
- () -
+
+
@@ -257,7 +257,7 @@
+
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index b07546e5e..8e67ccdfc 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -202,12 +202,12 @@ export class TransactionsListComponent implements OnInit, OnChanges { for (const address of this.addresses) { switch (address.length) { case 130: { - if (v.scriptpubkey === '21' + address + 'ac') { + if (v.scriptpubkey === '41' + address + 'ac') { return v.value; } } break; case 66: { - if (v.scriptpubkey === '41' + address + 'ac') { + if (v.scriptpubkey === '21' + address + 'ac') { return v.value; } } break; @@ -224,12 +224,12 @@ export class TransactionsListComponent implements OnInit, OnChanges { for (const address of this.addresses) { switch (address.length) { case 130: { - if (v.prevout?.scriptpubkey === '21' + address + 'ac') { + if (v.prevout?.scriptpubkey === '41' + address + 'ac') { return v.prevout?.value; } } break; case 66: { - if (v.prevout?.scriptpubkey === '41' + address + 'ac') { + if (v.prevout?.scriptpubkey === '21' + address + 'ac') { return v.prevout?.value; } } break; @@ -258,6 +258,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50'); if (tx.vin[i].witness.length > (hasAnnex ? 2 : 1) && tx.vin[i].witness[tx.vin[i].witness.length - (hasAnnex ? 3 : 2)].includes('0063036f7264')) { tx.vin[i].isInscription = true; + tx.largeInput = true; } } } @@ -268,6 +269,9 @@ export class TransactionsListComponent implements OnInit, OnChanges { } } } + + tx.largeInput = tx.largeInput || tx.vin.some(vin => (vin?.prevout?.value > 1000000000)); + tx.largeOutput = tx.vout.some(vout => (vout?.value > 1000000000)); }); if (this.blockTime && this.transactions?.length && this.currency) { @@ -351,8 +355,12 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.electrsApiService.getTransaction$(tx.txid) .subscribe((newTx) => { tx['@vinLoaded'] = true; + let temp = tx.vin; tx.vin = newTx.vin; tx.fee = newTx.fee; + for (const [index, vin] of temp.entries()) { + newTx.vin[index].isInscription = vin.isInscription; + } this.ref.markForCheck(); }); } diff --git a/frontend/src/app/components/twitter-login/twitter-login.component.html b/frontend/src/app/components/twitter-login/twitter-login.component.html index 6ff40bd50..5e687341c 100644 --- a/frontend/src/app/components/twitter-login/twitter-login.component.html +++ b/frontend/src/app/components/twitter-login/twitter-login.component.html @@ -1,6 +1,6 @@ - + style="background-color: rgb(31, 35, 40)" [style]="width ? 'width: ' + width : ''"> {{ buttonString }} + diff --git a/frontend/src/app/components/wallet/wallet.component.html b/frontend/src/app/components/wallet/wallet.component.html index 52b7b02a5..9aa82b818 100644 --- a/frontend/src/app/components/wallet/wallet.component.html +++ b/frontend/src/app/components/wallet/wallet.component.html @@ -1,6 +1,6 @@
-

Wallet

+

{{ walletName }}

@@ -74,6 +74,36 @@ +
+ +
+

Transactions

+
+ + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+ + +
+ +
+
+ +
diff --git a/frontend/src/app/components/wallet/wallet.component.ts b/frontend/src/app/components/wallet/wallet.component.ts index ce44250e9..43cc7ee80 100644 --- a/frontend/src/app/components/wallet/wallet.component.ts +++ b/frontend/src/app/components/wallet/wallet.component.ts @@ -9,6 +9,8 @@ import { of, Observable, Subscription } from 'rxjs'; import { SeoService } from '@app/services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { WalletAddress } from '@interfaces/node-api.interface'; +import { ElectrsApiService } from '@app/services/electrs-api.service'; +import { AudioService } from '@app/services/audio.service'; class WalletStats implements ChainStats { addresses: string[]; @@ -24,6 +26,7 @@ class WalletStats implements ChainStats { acc.funded_txo_sum += stat.funded_txo_sum; acc.spent_txo_count += stat.spent_txo_count; acc.spent_txo_sum += stat.spent_txo_sum; + acc.tx_count += stat.tx_count; return acc; }, { funded_txo_count: 0, @@ -109,12 +112,17 @@ export class WalletComponent implements OnInit, OnDestroy { addressStrings: string[] = []; walletName: string; isLoadingWallet = true; + isLoadingTransactions = true; + transactions: Transaction[]; + totalTransactionCount: number; + retryLoadMore = false; wallet$: Observable>; walletAddresses$: Observable>; walletSummary$: Observable; walletStats$: Observable; error: any; walletSubscription: Subscription; + transactionSubscription: Subscription; collapseAddresses: boolean = true; @@ -129,6 +137,8 @@ export class WalletComponent implements OnInit, OnDestroy { private websocketService: WebsocketService, private stateService: StateService, private apiService: ApiService, + private electrsApiService: ElectrsApiService, + private audioService: AudioService, private seoService: SeoService, ) { } @@ -172,6 +182,21 @@ export class WalletComponent implements OnInit, OnDestroy { }), switchMap(initial => this.stateService.walletTransactions$.pipe( startWith(null), + tap((transactions) => { + if (!transactions?.length) { + return; + } + for (const transaction of transactions) { + const tx = this.transactions.find((t) => t.txid === transaction.txid); + if (tx) { + tx.status = transaction.status; + } else { + this.transactions.unshift(transaction); + } + } + this.transactions = this.transactions.slice(); + this.audioService.playSound('magic'); + }), scan((wallet, walletTransactions) => { for (const tx of (walletTransactions || [])) { const funded: Record = {}; @@ -267,8 +292,57 @@ export class WalletComponent implements OnInit, OnDestroy { return stats; }, walletStats), ); - }), + }) ); + + this.transactionSubscription = this.wallet$.pipe( + switchMap(wallet => { + const addresses = Object.keys(wallet).map(addr => this.normalizeAddress(addr)); + return this.electrsApiService.getAddressesTransactions$(addresses); + }), + map(transactions => { + // only confirmed transactions supported for now + return transactions.filter(tx => tx.status.confirmed).sort((a, b) => b.status.block_height - a.status.block_height); + }), + catchError((error) => { + console.log(error); + this.error = error; + this.seoService.logSoft404(); + this.isLoadingWallet = false; + return of([]); + }) + ).subscribe((transactions: Transaction[] | null) => { + if (!transactions) { + return; + } + this.transactions = transactions; + this.isLoadingTransactions = false; + }); + } + + loadMore(): void { + if (this.isLoadingTransactions || this.fullyLoaded) { + return; + } + this.isLoadingTransactions = true; + this.retryLoadMore = false; + this.electrsApiService.getAddressesTransactions$(this.addressStrings, this.transactions[this.transactions.length - 1].txid) + .subscribe((transactions: Transaction[]) => { + if (transactions && transactions.length) { + this.transactions = this.transactions.concat(transactions.sort((a, b) => b.status.block_height - a.status.block_height)); + } else { + this.fullyLoaded = true; + } + this.isLoadingTransactions = false; + }, + (error) => { + this.isLoadingTransactions = false; + this.retryLoadMore = true; + // In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page. + if (error.status === 422) { + window.location.reload(); + } + }); } deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { @@ -299,5 +373,6 @@ export class WalletComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.websocketService.stopTrackingWallet(); this.walletSubscription.unsubscribe(); + this.transactionSubscription.unsubscribe(); } } diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index 1f83cabc9..c32baa3f7 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -13,96 +13,2442 @@ const emptyCodeSample = { const showJsExamplesDefault = { "": true, "testnet": true, "signet": true, "liquid": true, "liquidtestnet": false }; const showJsExamplesDefaultFalse = { "": false, "testnet": false, "signet": false, "liquid": false, "liquidtestnet": false }; -export const wsApiDocsData = { - showJsExamples: showJsExamplesDefault, - codeTemplate: { - curl: `/api/v1/ws`, - commonJS: ` - const { %{0}: { websocket } } = mempoolJS(); - - const ws = websocket.initClient({ - options: ['blocks', 'stats', 'mempool-blocks', 'live-2h-chart'], - }); - - ws.addEventListener('message', function incoming({data}) { - const res = JSON.parse(data.toString()); - if (res.block) { - document.getElementById("result-blocks").textContent = JSON.stringify(res.block, undefined, 2); - } - if (res.mempoolInfo) { - document.getElementById("result-mempool-info").textContent = JSON.stringify(res.mempoolInfo, undefined, 2); - } - if (res.transactions) { - document.getElementById("result-transactions").textContent = JSON.stringify(res.transactions, undefined, 2); - } - if (res["mempool-blocks"]) { - document.getElementById("result-mempool-blocks").textContent = JSON.stringify(res["mempool-blocks"], undefined, 2); - } - }); - `, - esModule: ` - const { %{0}: { websocket } } = mempoolJS(); - - const ws = websocket.initServer({ - options: ["blocks", "stats", "mempool-blocks", "live-2h-chart"], - }); - - ws.on("message", function incoming(data) { - const res = JSON.parse(data.toString()); - if (res.block) { - console.log(res.block); - } - if (res.mempoolInfo) { - console.log(res.mempoolInfo); - } - if (res.transactions) { - console.log(res.transactions); - } - if (res["mempool-blocks"]) { - console.log(res["mempool-blocks"]); - } - }); - `, - python: `import websocket -import _thread -import time -import rel -import json - -rel.safe_read() - -def on_message(ws, message): - print(json.loads(message)) - -def on_error(ws, error): - print(error) - -def on_close(ws, close_status_code, close_msg): - print("### closed ###") - -def on_open(ws): - message = { "action": "init" } - ws.send(json.dumps(message)) - message = { "action": "want", "data": ['blocks', 'stats', 'mempool-blocks', 'live-2h-chart', 'watch-mempool'] } - ws.send(json.dumps(message)) - -if __name__ == "__main__": - ws = websocket.WebSocketApp("wss://mempool.space/api/v1/ws", - on_open=on_open, - on_message=on_message, - on_error=on_error, - on_close=on_close) - - ws.run_forever(dispatcher=rel) # Set dispatcher to automatic reconnection - rel.signal(2, rel.abort) # Keyboard Interrupt - rel.dispatch() - `, +export const wsApiDocsData = [ + { + type: "category", + category: "general", + fragment: "general", + title: "General", + showConditions: bitcoinNetworks.concat(liquidNetworks) }, - codeSampleMainnet: emptyCodeSample, - codeSampleTestnet: emptyCodeSample, - codeSampleSignet: emptyCodeSample, - codeSampleLiquid: emptyCodeSample, -}; + { + type: "endpoint", + category: "general", + fragment: "live-data", + title: "Live Data", + description: { + default: "Subscribe to live data. Available: blocks, mempool-block, live-2h-chart, and stats." + }, + payload: '{ "action": "want", "data": ["mempool-blocks", "stats"] }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-blocks": [ + { + "blockSize": 1801614, + "blockVSize": 997936.5, + "nTx": 3391, + "totalFees": 8170664, + "medianFee": 6.011217160720601, + "feeRange": [ + 4.584615384615384, + 5, + 5.100456621004566, + 6.002319288751449, + 7.235398230088496, + 10.377668308702791, + 200 + ] + }, + ... + { + "blockSize": 198543075, + "blockVSize": 101691348, + "nTx": 249402, + "totalFees": 135312667, + "medianFee": 1.2559438783834156, + "feeRange": [ + 1.000685629033809, + 1.0020213063577312, + 1.0019080827758888, + 1.0227913345013278, + 1.1188648002395873, + 1.2559438783834156, + 1.4077952614964329, + 1.4079805737077244, + 1.5106880342499638, + 2.003440424869914, + 2.2713888268854894 + ] + } + ], + "mempoolInfo": { + "loaded": true, + "size": 264505, + "bytes": 108875402, + "usage": 649908688, + "total_fee": 1.61036575, + "maxmempool": 300000000, + "mempoolminfee": 0.00001858, + "minrelaytxfee": 0.00001, + "incrementalrelayfee": 0.00001, + "unbroadcastcount": 0, + "fullrbf": true + }, + "vBytesPerSecond": 1651, + "fees": { + "fastestFee": 7, + "halfHourFee": 6, + "hourFee": 5, + "economyFee": 4, + "minimumFee": 2 + }, + "da": { + "progressPercent": 32.49007936507937, + "difficultyChange": 0.7843046881601534, + "estimatedRetargetDate": 1735514828279, + "remainingBlocks": 1361, + "remainingTime": 811481279, + "previousRetarget": 4.429396745461176, + "previousTime": 1734312810, + "nextRetargetHeight": 876960, + "timeAvg": 596239, + "adjustedTimeAvg": 596239, + "timeOffset": 0, + "expectedBlocks": 650.895 + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-blocks": [ + { + "blockSize": 1009960, + "blockVSize": 997827.25, + "nTx": 3545, + "totalFees": 2844117938, + "medianFee": 2524.178404298769, + "feeRange": [ + 2010.9044259140476, + 2011.0887096774193, + 2011.2914608327453, + 2441.5893066980025, + 3541.35960591133, + 3936.6254416961133, + 6031.746031746032 + ] + }, + ... + ], + "mempoolInfo": { + "loaded": true, + "size": 517666, + "bytes": 168219654, + "usage": 855583264, + "total_fee": 133.53837564, + "maxmempool": 4096000000, + "mempoolminfee": 0.00001, + "minrelaytxfee": 0.00001, + "incrementalrelayfee": 0.00001, + "unbroadcastcount": 0, + "fullrbf": true + }, + "vBytesPerSecond": 358, + "fees": { + "fastestFee": 2525, + "halfHourFee": 2268, + "hourFee": 2082, + "economyFee": 2, + "minimumFee": 1 + }, + "da": { + "progressPercent": 45.882936507936506, + "difficultyChange": -51.21445794134847, + "estimatedRetargetDate": 1736046916382, + "remainingBlocks": 1091, + "remainingTime": 1343241382, + "previousRetarget": 255.61790932023905, + "previousTime": 1733564813, + "nextRetargetHeight": 3538080, + "timeAvg": 1200000, + "adjustedTimeAvg": 1231202, + "timeOffset": 0, + "expectedBlocks": 1898.1033333333332 + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{"mempool-blocks": [ + { + "blockSize": 1009960, + "blockVSize": 997827.25, + "nTx": 3545, + "totalFees": 2844117938, + "medianFee": 2524.178404298769, + "feeRange": [ + 2010.9044259140476, + 2011.0887096774193, + 2011.2914608327453, + 2441.5893066980025, + 3541.35960591133, + 3936.6254416961133, + 6031.746031746032 + ] + }, + ... + ], + "mempoolInfo": { + "loaded": true, + "size": 59, + "bytes": 9834, + "usage": 68832, + "total_fee": 0.00013935, + "maxmempool": 4096000000, + "mempoolminfee": 0.00001, + "minrelaytxfee": 0.00001, + "incrementalrelayfee": 0.00001, + "unbroadcastcount": 0, + "fullrbf": true + }, + "vBytesPerSecond": 28, + "da": { + "progressPercent": 68.60119047619048, + "difficultyChange": -2.913529439274176, + "estimatedRetargetDate": 1735095294116, + "remainingBlocks": 633, + "remainingTime": 391480116, + "previousRetarget": 2.0685719720386118, + "previousTime": 1733848494, + "nextRetargetHeight": 227808, + "timeAvg": 618452, + "adjustedTimeAvg": 618452, + "timeOffset": 0, + "expectedBlocks": 1425.5333333333333 + }, + "fees": { + "fastestFee": 1, + "halfHourFee": 1, + "hourFee": 1, + "economyFee": 1, + "minimumFee": 1 + }, +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-blocks": [ + { + "blockSize": 27409, + "blockVSize": 7675, + "nTx": 2, + "totalFees": 769, + "medianFee": 0, + "feeRange": [ + 0.10012450036039577, + 0.10012450036039577, + 0.10012450036039577, + 0.10012450036039577, + 0.10012450036039577, + 0.10012450036039577, + 0.10012450036039577 + ] + } + ], + "mempoolInfo": { + "loaded": true, + "size": 2, + "bytes": 7676, + "usage": 3568, + "total_fee": 0.00000769, + "maxmempool": 300000000, + "mempoolminfee": 0.000001, + "minrelaytxfee": 0.000001, + "unbroadcastcount": 0 + }, + "vBytesPerSecond": 60, + "fees": { + "fastestFee": 0.1, + "halfHourFee": 0.1, + "hourFee": 0.1, + "economyFee": 0.1, + "minimumFee": 0.1 + }, + "da": { + "progressPercent": 4.315476190476191, + "difficultyChange": null, + "estimatedRetargetDate": null, + "remainingBlocks": 1929, + "remainingTime": null, + "previousRetarget": null, + "previousTime": 1734698648, + "nextRetargetHeight": 3173184, + "timeAvg": 60448, + "adjustedTimeAvg": null, + "timeOffset": 0, + "expectedBlocks": 8.765 + } +}` + } + } + } + }, + { + type: "category", + category: "addresses", + fragment: "addresses", + title: "Addresses", + showConditions: bitcoinNetworks.concat(liquidNetworks) + }, + { + type: "endpoint", + category: "addresses", + fragment: "track-address", + title: "Track Address", + description: { + default: "Subscribe to a single address to receive live updates on new transactions having that address in input or output. address-transactions field contains new mempool transactions, and block-transactions contains new confirmed transactions." + }, + payload: '{ "track-address": "bc1qeldw4mqns26wew8swgpkt3fs364w3ehs046w2f" }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "block-transactions": [ + { + "txid": "9d3ea0d131c45450c135d549b62032019bc47a80368e14edc72caf38f5a88033", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "69da555a9c69788a3a081958457894e56b1ee6766bc72cecf881b1b4f327f78b", + "vout": 0, + "prevout": { + "scriptpubkey": "a914c9848245ae4f5d5934b5cbdfb79e04cdd337470b87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 c9848245ae4f5d5934b5cbdfb79e04cdd337470b OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3L4YUynB4X44rJBY9CmiLMN8Wjti49JCYB", + "value": 24962957 + }, + "scriptsig": "0048304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f0147304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e014c695221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_72 304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f01 OP_PUSHBYTES_71 304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e01 OP_PUSHDATA1 5221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c0 OP_PUSHBYTES_33 03556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb695 OP_PUSHBYTES_33 031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb OP_PUSHNUM_3 OP_CHECKMULTISIG" + }, + ... + { + "txid": "43852d32c7ae6d362d446d090daa4d389f78ec77e6693f9248cd924dc0b1ecc3", + "vout": 1, + "prevout": { + "scriptpubkey": "a914a3aff5f5765f167c1582fd85517ddde83174118187", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 a3aff5f5765f167c1582fd85517ddde831741181 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3GcWrnGFoNzbn1KaiP5czS5xPELdWcgDX2", + "value": 1719827 + }, + "scriptsig": "0047304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201483045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff144014c69522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_71 304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201 OP_PUSHBYTES_72 3045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff14401 OP_PUSHDATA1 522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 03650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b28 OP_PUSHBYTES_33 02510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a OP_PUSHBYTES_33 02985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f8 OP_PUSHNUM_3 OP_CHECKMULTISIG" + } + ], + "vout": [ + { + "scriptpubkey": "0014292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1q9yhuu4yty2xd8hmrxnw4yhavvtn7khm62uw38p", + "value": 57000 + }, + ... + { + "scriptpubkey": "0020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d", + "value": 17343523 + } + ], + "size": 5514, + "weight": 22056, + "sigops": 208, + "fee": 44000, + "status": { + "confirmed": true, + "block_height": 875602, + "block_hash": "000000000000000000016c0639b6c1a34d6659c231aa2de5849ab3377ed75020", + "block_time": 1734704791 + }, + "order": 864069877, + "vsize": 5514, + "adjustedVsize": 5514, + "feePerVsize": 7.979688066739209, + "adjustedFeePerVsize": 7.979688066739209, + "effectiveFeePerVsize": 7.979688066739209, + "firstSeen": 1734704590, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 191567 + }, + "flags": 1099511659526 + } + ] +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "block-transactions": [ + { + "txid": "9d3ea0d131c45450c135d549b62032019bc47a80368e14edc72caf38f5a88033", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "69da555a9c69788a3a081958457894e56b1ee6766bc72cecf881b1b4f327f78b", + "vout": 0, + "prevout": { + "scriptpubkey": "a914c9848245ae4f5d5934b5cbdfb79e04cdd337470b87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 c9848245ae4f5d5934b5cbdfb79e04cdd337470b OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3L4YUynB4X44rJBY9CmiLMN8Wjti49JCYB", + "value": 24962957 + }, + "scriptsig": "0048304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f0147304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e014c695221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_72 304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f01 OP_PUSHBYTES_71 304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e01 OP_PUSHDATA1 5221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c0 OP_PUSHBYTES_33 03556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb695 OP_PUSHBYTES_33 031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb OP_PUSHNUM_3 OP_CHECKMULTISIG" + }, + ... + { + "txid": "43852d32c7ae6d362d446d090daa4d389f78ec77e6693f9248cd924dc0b1ecc3", + "vout": 1, + "prevout": { + "scriptpubkey": "a914a3aff5f5765f167c1582fd85517ddde83174118187", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 a3aff5f5765f167c1582fd85517ddde831741181 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3GcWrnGFoNzbn1KaiP5czS5xPELdWcgDX2", + "value": 1719827 + }, + "scriptsig": "0047304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201483045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff144014c69522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_71 304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201 OP_PUSHBYTES_72 3045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff14401 OP_PUSHDATA1 522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 03650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b28 OP_PUSHBYTES_33 02510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a OP_PUSHBYTES_33 02985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f8 OP_PUSHNUM_3 OP_CHECKMULTISIG" + } + ], + "vout": [ + { + "scriptpubkey": "0014292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1q9yhuu4yty2xd8hmrxnw4yhavvtn7khm62uw38p", + "value": 57000 + }, + ... + { + "scriptpubkey": "0020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d", + "value": 17343523 + } + ], + "size": 5514, + "weight": 22056, + "sigops": 208, + "fee": 44000, + "status": { + "confirmed": true, + "block_height": 875602, + "block_hash": "000000000000000000016c0639b6c1a34d6659c231aa2de5849ab3377ed75020", + "block_time": 1734704791 + }, + "order": 864069877, + "vsize": 5514, + "adjustedVsize": 5514, + "feePerVsize": 7.979688066739209, + "adjustedFeePerVsize": 7.979688066739209, + "effectiveFeePerVsize": 7.979688066739209, + "firstSeen": 1734704590, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 191567 + }, + "flags": 1099511659526 + } + ] +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "block-transactions": [ + { + "txid": "9d3ea0d131c45450c135d549b62032019bc47a80368e14edc72caf38f5a88033", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "69da555a9c69788a3a081958457894e56b1ee6766bc72cecf881b1b4f327f78b", + "vout": 0, + "prevout": { + "scriptpubkey": "a914c9848245ae4f5d5934b5cbdfb79e04cdd337470b87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 c9848245ae4f5d5934b5cbdfb79e04cdd337470b OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3L4YUynB4X44rJBY9CmiLMN8Wjti49JCYB", + "value": 24962957 + }, + "scriptsig": "0048304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f0147304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e014c695221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_72 304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f01 OP_PUSHBYTES_71 304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e01 OP_PUSHDATA1 5221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c0 OP_PUSHBYTES_33 03556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb695 OP_PUSHBYTES_33 031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb OP_PUSHNUM_3 OP_CHECKMULTISIG" + }, + ... + { + "txid": "43852d32c7ae6d362d446d090daa4d389f78ec77e6693f9248cd924dc0b1ecc3", + "vout": 1, + "prevout": { + "scriptpubkey": "a914a3aff5f5765f167c1582fd85517ddde83174118187", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 a3aff5f5765f167c1582fd85517ddde831741181 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3GcWrnGFoNzbn1KaiP5czS5xPELdWcgDX2", + "value": 1719827 + }, + "scriptsig": "0047304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201483045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff144014c69522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_71 304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201 OP_PUSHBYTES_72 3045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff14401 OP_PUSHDATA1 522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 03650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b28 OP_PUSHBYTES_33 02510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a OP_PUSHBYTES_33 02985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f8 OP_PUSHNUM_3 OP_CHECKMULTISIG" + } + ], + "vout": [ + { + "scriptpubkey": "0014292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1q9yhuu4yty2xd8hmrxnw4yhavvtn7khm62uw38p", + "value": 57000 + }, + ... + { + "scriptpubkey": "0020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d", + "value": 17343523 + } + ], + "size": 5514, + "weight": 22056, + "sigops": 208, + "fee": 44000, + "status": { + "confirmed": true, + "block_height": 875602, + "block_hash": "000000000000000000016c0639b6c1a34d6659c231aa2de5849ab3377ed75020", + "block_time": 1734704791 + }, + "order": 864069877, + "vsize": 5514, + "adjustedVsize": 5514, + "feePerVsize": 7.979688066739209, + "adjustedFeePerVsize": 7.979688066739209, + "effectiveFeePerVsize": 7.979688066739209, + "firstSeen": 1734704590, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 191567 + }, + "flags": 1099511659526 + } + ] +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "block-transactions": [ + { + "txid": "9d3ea0d131c45450c135d549b62032019bc47a80368e14edc72caf38f5a88033", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "69da555a9c69788a3a081958457894e56b1ee6766bc72cecf881b1b4f327f78b", + "vout": 0, + "prevout": { + "scriptpubkey": "a914c9848245ae4f5d5934b5cbdfb79e04cdd337470b87", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 c9848245ae4f5d5934b5cbdfb79e04cdd337470b OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3L4YUynB4X44rJBY9CmiLMN8Wjti49JCYB", + "value": 24962957 + }, + "scriptsig": "0048304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f0147304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e014c695221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_72 304502210099219ee0cd5da341650078e3c63885b3cc2211069f2551cf436e0100f421e1760220349b4ec284255b458d6da539fa17314e8330459e0a653c254f775d4ec8f32b3f01 OP_PUSHBYTES_71 304402203a8353c5ee76a2e266432e5f993f882e05725297e64c0833cf44719f7dda8d3b022058a2f72e7739efd21657b4943cac60a0a3c749e712787f0e85726da4c3adcf8e01 OP_PUSHDATA1 5221027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c02103556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb69521031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb53ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 027f2a0df8e86535d08ca3e766a178f90c813d2dd1d55b0166e82518efbffb18c0 OP_PUSHBYTES_33 03556a35844b517e2fc8216701e2e0a64dbcbe62ad420ac6dd73dc79e69efeb695 OP_PUSHBYTES_33 031ef21bd55171032b7aec21ec82932735fb986f1d4d8611feee62ab38acf4a6bb OP_PUSHNUM_3 OP_CHECKMULTISIG" + }, + ... + { + "txid": "43852d32c7ae6d362d446d090daa4d389f78ec77e6693f9248cd924dc0b1ecc3", + "vout": 1, + "prevout": { + "scriptpubkey": "a914a3aff5f5765f167c1582fd85517ddde83174118187", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 a3aff5f5765f167c1582fd85517ddde831741181 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "3GcWrnGFoNzbn1KaiP5czS5xPELdWcgDX2", + "value": 1719827 + }, + "scriptsig": "0047304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201483045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff144014c69522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "scriptsig_asm": "OP_0 OP_PUSHBYTES_71 304402205f83d22a0476158aa0986682c96ce2b2dab26c814968dba62905cdfeef1b3ac7022059438a3439bb18bd49242010c8a276ea6f1810d523042e679fa6679d60e89e0201 OP_PUSHBYTES_72 3045022100eb085df09e0fb4894090a5f39b9f2188392f7ac2847ed8255629baffc7371f170220120463b91d6c4bb8968fb3eda9012b88d13d8ca71de28e7a64b1dd88282ff14401 OP_PUSHDATA1 522103650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b282102510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a2102985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f853ae", + "is_coinbase": false, + "sequence": 4294967295, + "inner_redeemscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 03650083cbc9cd1da1224e0780bce1ee8abd5150c5252defd0edeccd3521610b28 OP_PUSHBYTES_33 02510ab30a6a97464ef0d61f71ec8b1d2325f12934ff15ba73579bfd0ac5f4fc1a OP_PUSHBYTES_33 02985b3be77f56a9a29c5f68d3c893d6c4d76ec8c07792f0291d375c29b71ee2f8 OP_PUSHNUM_3 OP_CHECKMULTISIG" + } + ], + "vout": [ + { + "scriptpubkey": "0014292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 292fce548b228cd3df6334dd525fac62e7eb5f7a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1q9yhuu4yty2xd8hmrxnw4yhavvtn7khm62uw38p", + "value": 57000 + }, + ... + { + "scriptpubkey": "0020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d", + "value": 17343523 + } + ], + "size": 5514, + "weight": 22056, + "sigops": 208, + "fee": 44000, + "status": { + "confirmed": true, + "block_height": 875602, + "block_hash": "000000000000000000016c0639b6c1a34d6659c231aa2de5849ab3377ed75020", + "block_time": 1734704791 + }, + "order": 864069877, + "vsize": 5514, + "adjustedVsize": 5514, + "feePerVsize": 7.979688066739209, + "adjustedFeePerVsize": 7.979688066739209, + "effectiveFeePerVsize": 7.979688066739209, + "firstSeen": 1734704590, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 191567 + }, + "flags": 1099511659526 + } + ] +}` + } + } + } + }, + { + type: "endpoint", + category: "addresses", + fragment: "track-addresses", + title: "Track Addresses", + description: { + default: "Subscribe to multiple addresses to receive live updates on new transactions having these addresses in input or output. Limits on the maximum number of tracked addresses apply. For higher tracking limits, consider upgrading to an enterprise sponsorship." + }, + payload: `{ + "track-addresses": [ + "bc1qeldw4mqns26wew8swgpkt3fs364w3ehs046w2f", + "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y" + ] +}`, + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "multi-address-transactions": { + "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y": { + "mempool": [], + "confirmed": [ + { + "txid": "1e4764f908f19b74284a889478b95d013c1bd36dc832dcb7eb36fe1801fed404", + "version": 2, + "locktime": 875625, + "vin": [ + { + "txid": "ce361fed5996aec6d440556383164e9e4e5b8be8c2a213c4b36ae711efda3b3f", + "vout": 1, + "prevout": { + "scriptpubkey": "0014257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qy4a6r67fs7p3m05wu4syry5zfqaldpvg8vsqzz", + "value": 1831200 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022028363f66fe74bdddf46d204cbf9844d4ef99d6fcb801f93f3ea1666ff51514340220058eb99790dd002323bd12afa0b62903cf72465d48c40cb11366dfa4eebbd87a01", + "020e625e13a81995f29ee828e31500b8454bd0b115f84dfa07d994eecd733efffa" + ], + "is_coinbase": false, + "sequence": 4294967294 + }, + ... + ], + "vout": [ + { + "scriptpubkey": "0020949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y", + "value": 2546637 + } + ], + "size": 351, + "weight": 756, + "sigops": 2, + "fee": 4206, + "status": { + "confirmed": true, + "block_height": 875626, + "block_hash": "0000000000000000000086de1f4815ff0f7f0411d846301c5efa1e437130dc22", + "block_time": 1734720142 + }, + "order": 81067521, + "vsize": 189, + "adjustedVsize": 189, + "feePerVsize": 22.253968253968253, + "adjustedFeePerVsize": 22.253968253968253, + "effectiveFeePerVsize": 22.253968253968253, + "firstSeen": 1734719830, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 134866.5 + }, + "flags": 1099511640074 + } + ], + "removed": [] + } + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "multi-address-transactions": { + "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y": { + "mempool": [], + "confirmed": [ + { + "txid": "1e4764f908f19b74284a889478b95d013c1bd36dc832dcb7eb36fe1801fed404", + "version": 2, + "locktime": 875625, + "vin": [ + { + "txid": "ce361fed5996aec6d440556383164e9e4e5b8be8c2a213c4b36ae711efda3b3f", + "vout": 1, + "prevout": { + "scriptpubkey": "0014257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qy4a6r67fs7p3m05wu4syry5zfqaldpvg8vsqzz", + "value": 1831200 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022028363f66fe74bdddf46d204cbf9844d4ef99d6fcb801f93f3ea1666ff51514340220058eb99790dd002323bd12afa0b62903cf72465d48c40cb11366dfa4eebbd87a01", + "020e625e13a81995f29ee828e31500b8454bd0b115f84dfa07d994eecd733efffa" + ], + "is_coinbase": false, + "sequence": 4294967294 + }, + ... + ], + "vout": [ + { + "scriptpubkey": "0020949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y", + "value": 2546637 + } + ], + "size": 351, + "weight": 756, + "sigops": 2, + "fee": 4206, + "status": { + "confirmed": true, + "block_height": 875626, + "block_hash": "0000000000000000000086de1f4815ff0f7f0411d846301c5efa1e437130dc22", + "block_time": 1734720142 + }, + "order": 81067521, + "vsize": 189, + "adjustedVsize": 189, + "feePerVsize": 22.253968253968253, + "adjustedFeePerVsize": 22.253968253968253, + "effectiveFeePerVsize": 22.253968253968253, + "firstSeen": 1734719830, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 134866.5 + }, + "flags": 1099511640074 + } + ], + "removed": [] + } + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "multi-address-transactions": { + "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y": { + "mempool": [], + "confirmed": [ + { + "txid": "1e4764f908f19b74284a889478b95d013c1bd36dc832dcb7eb36fe1801fed404", + "version": 2, + "locktime": 875625, + "vin": [ + { + "txid": "ce361fed5996aec6d440556383164e9e4e5b8be8c2a213c4b36ae711efda3b3f", + "vout": 1, + "prevout": { + "scriptpubkey": "0014257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 257ba1ebc987831dbe8ee560419282483bf68588", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qy4a6r67fs7p3m05wu4syry5zfqaldpvg8vsqzz", + "value": 1831200 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022028363f66fe74bdddf46d204cbf9844d4ef99d6fcb801f93f3ea1666ff51514340220058eb99790dd002323bd12afa0b62903cf72465d48c40cb11366dfa4eebbd87a01", + "020e625e13a81995f29ee828e31500b8454bd0b115f84dfa07d994eecd733efffa" + ], + "is_coinbase": false, + "sequence": 4294967294 + }, + ... + ], + "vout": [ + { + "scriptpubkey": "0020949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_32 949e53d22b4844b92076acb2414abbfd0a96171b87d91c55063269ee3ed1b1b6", + "scriptpubkey_type": "v0_p2wsh", + "scriptpubkey_address": "bc1qjj09853tfpztjgrk4jeyzj4ml59fv9cmslv3c4gxxf57u0k3kxmqllx29y", + "value": 2546637 + } + ], + "size": 351, + "weight": 756, + "sigops": 2, + "fee": 4206, + "status": { + "confirmed": true, + "block_height": 875626, + "block_hash": "0000000000000000000086de1f4815ff0f7f0411d846301c5efa1e437130dc22", + "block_time": 1734720142 + }, + "order": 81067521, + "vsize": 189, + "adjustedVsize": 189, + "feePerVsize": 22.253968253968253, + "adjustedFeePerVsize": 22.253968253968253, + "effectiveFeePerVsize": 22.253968253968253, + "firstSeen": 1734719830, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 134866.5 + }, + "flags": 1099511640074 + } + ], + "removed": [] + } + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "multi-address-transactions": { + "ex1qzq0h0wvnnh9xpd508fzxaft0nu9wjmdvzalu6f": { + "mempool": [], + "confirmed": [ + { + "txid": "d61ad73b64895ccabd32816643554c676891bdb52da0fba2b37079e04c4c4b2c", + "version": 2, + "locktime": 3171528, + "vin": [ + { + "txid": "4847a0627952a0bcad6c8947d46a0e5b13eefbcfbf76246ea16a1a7c82bcc49b", + "vout": 2, + "prevout": { + "scriptpubkey": "00144d72c2967e1a581c0e71e82d65e99523a9149d02", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 4d72c2967e1a581c0e71e82d65e99523a9149d02", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1qf4ev99n7rfvpcrn3aqkkt6v4yw53f8gznv9paa", + "valuecommitment": "09af208bbc0b9809aff4368dee81f74f178f77f844e7dfc5d70615bc757fa8b2f9", + "assetcommitment": "0a2ca17c42fadd887373c371e44cf49c6cd64c3081e23eef3275bdace0b8c674b5" + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "30440220653c6e1bd3de5bd9a56cbb6eb246834724667a5c5d12dc07107edc7c72bd6634022008d1f770dc9ba624bb250bba3a5254aa633f01a9bcb2a85aedc5b251e338b7b301", + "03fb2f0245e19f9e886fce54894558bbbcf50bf9576245e60a4c9780f7447eaf22" + ], + "is_coinbase": false, + "sequence": 4294967294, + "is_pegin": false + }, + { + "txid": "ea5f690853ece5549807862a153357092c4f7dbe10886b86b84f87a3201dd8dc", + "vout": 0, + "prevout": { + "scriptpubkey": "0014a5996021b4001325b1aa85c3bf400516855a6e05", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a5996021b4001325b1aa85c3bf400516855a6e05", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1q5kvkqgd5qqfjtvd2shpm7sq9z6z45ms9ma7ywz", + "valuecommitment": "09f36fde0f51390cdf2ee6830b3c696569c3f9c5855ce26bd4f6d0280a83b86ecf", + "assetcommitment": "0bfcd8fcfaebfa41b596a89aa55fbf2eaa8c383ec71e8d9d0d461ea645d8d1bc45" + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "30440220033208f3e37c35009ba00472a67899222afbd5cc12b0d3906d2eec6f50a058510220607d4c5de43459e38158ee1cbe5e6a74c155041cecdbf961a077a952ee1e543601", + "0394d6ecb2f5db9fdeb0f7ac5301ea148704fc6986fdb8181bddc1d2eec9e99c32" + ], + "is_coinbase": false, + "sequence": 4294967294, + "is_pegin": false + }, + { + "txid": "23e63b888d5da3ce1193bb4a74a0762d78904cfa7a6307ff47e91054d961208b", + "vout": 2, + "prevout": { + "scriptpubkey": "00144990783e871e57fa2499f00c5f6f4ddc2602e7c8", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 4990783e871e57fa2499f00c5f6f4ddc2602e7c8", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1qfxg8s058retl5fye7qx97m6dmsnq9e7gq0dcee", + "valuecommitment": "0816440695f0c47ce471c7e10a93d36aee4554b46ed269bfa8390dd9db69409537", + "assetcommitment": "0a5a0eb7cab779cb6ce5d6517c73e244075eea15fbd54a7beb34710862aef58359" + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022051e5482a486f55cfd5ae25062b0252e13de9bfa11a9c7e5f608ac6e03c62dc8902201d2b47a27fc07999973ec44e4569b4b3fcc338b1c3977173cfeab9cccde0b3e301", + "025991a68daafc95494019c228855999db8f19c872fd3f58bac6ff149db7b53cff" + ], + "is_coinbase": false, + "sequence": 4294967294, + "is_pegin": false + }, + { + "txid": "2bbeb9440d3c08a1d3cd9acf5959ee740a6a64ffcaa4aa2b43e30026a2a40334", + "vout": 2, + "prevout": { + "scriptpubkey": "00144d72c2967e1a581c0e71e82d65e99523a9149d02", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 4d72c2967e1a581c0e71e82d65e99523a9149d02", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1qf4ev99n7rfvpcrn3aqkkt6v4yw53f8gznv9paa", + "valuecommitment": "09d0c574d61d50065a2e398fb7252315b65176bf97de1d180d337f3aadfaa0e53e", + "assetcommitment": "0b82ede6b9a6cb9505a7f6bcc76f72caa3228de193debf4e586d60baebeaef0ab5" + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022033ab9ea81a21b0f917792097ed69ab3724957a5e5d3a0430a0b2a16e0a74d8750220202859dc7e53998f5dc4b424321b7a711e3ff6517422a099504b337e30ec8acb01", + "03fb2f0245e19f9e886fce54894558bbbcf50bf9576245e60a4c9780f7447eaf22" + ], + "is_coinbase": false, + "sequence": 4294967294, + "is_pegin": false + } + ], + "vout": [ + { + "scriptpubkey": "a914e185d1192f34d55ba3fbd15408168f339683d80287", + "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 e185d1192f34d55ba3fbd15408168f339683d802 OP_EQUAL", + "scriptpubkey_type": "p2sh", + "scriptpubkey_address": "H3jyk9ipDU5efhHW9n52xCY78HNFAQTy78", + "valuecommitment": "08f676101a27d784f1f89765ff33ad5a1e95ab2081da76b29ef97bdfaf309e1318", + "assetcommitment": "0a115106f540daae5a0a7cf66dcf07a69dc2faffb917e82f340bcdfc7da143228b" + }, + { + "scriptpubkey": "0014101f77b9939dca60b68f3a446ea56f9f0ae96dac", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 101f77b9939dca60b68f3a446ea56f9f0ae96dac", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1qzq0h0wvnnh9xpd508fzxaft0nu9wjmdvzalu6f", + "valuecommitment": "09bec5886710680e125b52ac99f6aee452984847cddc57abaa96eb8cc360f80104", + "assetcommitment": "0adfd85f43988146c1878e98bc5e6206f368280cb03760c37e86ec0bd39005d0cd" + }, + { + "scriptpubkey": "00140fe27684e78285d508073f2b8a3a6c884515d1a9", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 0fe27684e78285d508073f2b8a3a6c884515d1a9", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "ex1qpl38dp88s2za2zq88u4c5wnv3pz3t5dfha22k9", + "valuecommitment": "08bef8c28296cf050802c943d46aa539d2f5280e9b9471db928746480815cf5457", + "assetcommitment": "0a5a032f72df6fba7f1acd7230f44cdf41ce27926e48e262ffdfea18efd19e0439" + }, + { + "scriptpubkey": "", + "scriptpubkey_asm": "", + "scriptpubkey_type": "fee", + "value": 394, + "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d" + } + ], + "size": 13955, + "weight": 15713, + "sigops": 0, + "fee": 394, + "status": { + "confirmed": true, + "block_height": 3171530, + "block_hash": "400270631b0f66d70cd6a045f36bb3f37c9076688fd496669d5da2a7245392d9", + "block_time": 1734720368 + }, + "order": 743132236, + "vsize": 3929, + "adjustedVsize": 3928.25, + "feePerVsize": 0.10029911538216763, + "adjustedFeePerVsize": 0.10029911538216763, + "effectiveFeePerVsize": 0.10027996945787732, + "firstSeen": 1734720314, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 6972.5 + }, + "flags": 1099511633962, + "cpfpChecked": true, + "cpfpUpdated": 1734720355424 + } + ], + "removed": [] + } + } +}` + } + } + } + }, + { + type: "category", + category: "transactions", + fragment: "transactions", + title: "Transactions", + showConditions: bitcoinNetworks.concat(liquidNetworks) + }, + { + type: "endpoint", + category: "transactions", + fragment: "track-tx", + title: "Track Transaction", + description: { + default: "Subscribe to a transaction to receive live updates on its confirmation status and position in the mempool." + }, + payload: '{ "track-tx": "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07" }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "txPosition": { + "txid": [ + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07" + ], + "position": { + "block": 0, + "vsize": 726868 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "txPosition": { + "txid": [ + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07" + ], + "position": { + "block": 0, + "vsize": 726868 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "txPosition": { + "txid": [ + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07" + ], + "position": { + "block": 0, + "vsize": 726868 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "txPosition": { + "txid": [ + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07" + ], + "position": { + "block": 0, + "vsize": 726868 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + } +}` + } + } + } + }, + { + type: "endpoint", + category: "transactions", + fragment: "track-txs", + title: "Track Transactions", + description: { + default: "Subscribe to multiple transactions to receive live updates on their status and position in the mempool. Limits on the maximum number of tracked addresses apply. For higher tracking limits, consider upgrading to an enterprise sponsorship." + }, + payload: `{ + "track-txs": [ + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07", + "941df06064c290b4627e92bdbf3bff7c0e97aab33e273c2a20404f9cfd21b607" + ] + }`, showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "tracked-txs": { + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07": { + "position": { + "block": 0, + "vsize": 434494 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + }, + "941df06064c290b4627e92bdbf3bff7c0e97aab33e273c2a20404f9cfd21b607": { + "position": { + "block": 2, + "vsize": 932479.5 + } + } + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "tracked-txs": { + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07": { + "position": { + "block": 0, + "vsize": 434494 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + }, + "941df06064c290b4627e92bdbf3bff7c0e97aab33e273c2a20404f9cfd21b607": { + "position": { + "block": 2, + "vsize": 932479.5 + } + } + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "tracked-txs": { + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07": { + "position": { + "block": 0, + "vsize": 434494 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + }, + "941df06064c290b4627e92bdbf3bff7c0e97aab33e273c2a20404f9cfd21b607": { + "position": { + "block": 2, + "vsize": 932479.5 + } + } + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "tracked-txs": { + "8a4666c6d22ce74fa47e1c4fdb09af556a234cc6a606539a75caf66ba44a2d07": { + "position": { + "block": 0, + "vsize": 434494 + }, + "cpfp": { + "ancestors": [ + { + "txid": "d509a6b8f36149588f9f48dc25fa5e37fc00dee781aed6da1113066c56f04879", + "fee": 605, + "weight": 520 + } + ], + "bestDescendant": null, + "descendants": [ + { + "txid": "28d3c592a9a8103d53c784aa539908f4dc5f9c463e179f0eae5dc5f349bdb00f", + "fee": 2501, + "weight": 816 + } + ], + "effectiveFeePerVsize": 5.12063778580024, + "sigops": 0, + "adjustedVsize": 130 + } + }, + "941df06064c290b4627e92bdbf3bff7c0e97aab33e273c2a20404f9cfd21b607": { + "position": { + "block": 2, + "vsize": 932479.5 + } + } + } +}` + } + } + } + }, + { + type: "category", + category: "mempool", + fragment: "mempool", + title: "Mempool", + showConditions: bitcoinNetworks.concat(liquidNetworks) + }, + { + type: "endpoint", + category: "mempool", + fragment: "track-mempool", + title: "Track Mempool", + description: { + default: "Subscribe to new mempool events, such as new transactions entering the mempool. Available fields: added, removed, mined, replaced.
Because this is potentially a lot of data, consider using the track-mempool-txids endpoint described below instead, or upgrade to an enterprise sponsorship." + }, + payload: '{ "track-mempool": true }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-transactions": { + "sequence": 81419, + "added": [ + { + "txid": "6229c0784bc776be22a5ee84e0e3d9b8f9e17843f079a8444b03bdc98b77d229", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "b4b324e3bff7ee0a7e664e8c03df1fe3a0bd53e5685ea6b10abb5f89ba1b2ead", + "vout": 5, + "prevout": { + "scriptpubkey": "76a914b54afb58f0faa9d1bde2ed755bc56ef1e4a4e24188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 b54afb58f0faa9d1bde2ed755bc56ef1e4a4e241 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1HXb8YtsgBhFWdYezjd6bt7Dw4UGKyZo54", + "value": 17000 + }, + "scriptsig": "4830450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a83649404012103cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "scriptsig_asm": "OP_PUSHBYTES_72 30450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a8364940401 OP_PUSHBYTES_33 03cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91401603bd82a5d5a6e8c6df5d9ae662b9fc5db60f288ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 01603bd82a5d5a6e8c6df5d9ae662b9fc5db60f2 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "18GxdcLgNtRUc8v5TNJtPnvoi8jMVWxvb", + "value": 10419 + }, + { + "scriptpubkey": "76a914338ad842d236486627834bf9f5e182c7a8aa937188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 338ad842d236486627834bf9f5e182c7a8aa9371 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "15hXntT6oUKNhtk4FWvuGPQJDX47wpbAaa", + "value": 5396 + } + ], + "size": 226, + "weight": 904, + "sigops": 8, + "fee": 1185, + "status": { + "confirmed": false + }, + "order": 701659019, + "vsize": 226, + "adjustedVsize": 226, + "feePerVsize": 5.243362831858407, + "adjustedFeePerVsize": 5.243362831858407, + "effectiveFeePerVsize": 5.243362831858407, + "firstSeen": 1734893382, + "uid": 429139, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 125270 + }, + "flags": 1099511628809 + }, + ... + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-transactions": { + "sequence": 81419, + "added": [ + { + "txid": "6229c0784bc776be22a5ee84e0e3d9b8f9e17843f079a8444b03bdc98b77d229", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "b4b324e3bff7ee0a7e664e8c03df1fe3a0bd53e5685ea6b10abb5f89ba1b2ead", + "vout": 5, + "prevout": { + "scriptpubkey": "76a914b54afb58f0faa9d1bde2ed755bc56ef1e4a4e24188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 b54afb58f0faa9d1bde2ed755bc56ef1e4a4e241 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1HXb8YtsgBhFWdYezjd6bt7Dw4UGKyZo54", + "value": 17000 + }, + "scriptsig": "4830450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a83649404012103cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "scriptsig_asm": "OP_PUSHBYTES_72 30450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a8364940401 OP_PUSHBYTES_33 03cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91401603bd82a5d5a6e8c6df5d9ae662b9fc5db60f288ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 01603bd82a5d5a6e8c6df5d9ae662b9fc5db60f2 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "18GxdcLgNtRUc8v5TNJtPnvoi8jMVWxvb", + "value": 10419 + }, + { + "scriptpubkey": "76a914338ad842d236486627834bf9f5e182c7a8aa937188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 338ad842d236486627834bf9f5e182c7a8aa9371 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "15hXntT6oUKNhtk4FWvuGPQJDX47wpbAaa", + "value": 5396 + } + ], + "size": 226, + "weight": 904, + "sigops": 8, + "fee": 1185, + "status": { + "confirmed": false + }, + "order": 701659019, + "vsize": 226, + "adjustedVsize": 226, + "feePerVsize": 5.243362831858407, + "adjustedFeePerVsize": 5.243362831858407, + "effectiveFeePerVsize": 5.243362831858407, + "firstSeen": 1734893382, + "uid": 429139, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 125270 + }, + "flags": 1099511628809 + }, + ... + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-transactions": { + "sequence": 81419, + "added": [ + { + "txid": "6229c0784bc776be22a5ee84e0e3d9b8f9e17843f079a8444b03bdc98b77d229", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "b4b324e3bff7ee0a7e664e8c03df1fe3a0bd53e5685ea6b10abb5f89ba1b2ead", + "vout": 5, + "prevout": { + "scriptpubkey": "76a914b54afb58f0faa9d1bde2ed755bc56ef1e4a4e24188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 b54afb58f0faa9d1bde2ed755bc56ef1e4a4e241 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1HXb8YtsgBhFWdYezjd6bt7Dw4UGKyZo54", + "value": 17000 + }, + "scriptsig": "4830450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a83649404012103cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "scriptsig_asm": "OP_PUSHBYTES_72 30450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a8364940401 OP_PUSHBYTES_33 03cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91401603bd82a5d5a6e8c6df5d9ae662b9fc5db60f288ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 01603bd82a5d5a6e8c6df5d9ae662b9fc5db60f2 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "18GxdcLgNtRUc8v5TNJtPnvoi8jMVWxvb", + "value": 10419 + }, + { + "scriptpubkey": "76a914338ad842d236486627834bf9f5e182c7a8aa937188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 338ad842d236486627834bf9f5e182c7a8aa9371 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "15hXntT6oUKNhtk4FWvuGPQJDX47wpbAaa", + "value": 5396 + } + ], + "size": 226, + "weight": 904, + "sigops": 8, + "fee": 1185, + "status": { + "confirmed": false + }, + "order": 701659019, + "vsize": 226, + "adjustedVsize": 226, + "feePerVsize": 5.243362831858407, + "adjustedFeePerVsize": 5.243362831858407, + "effectiveFeePerVsize": 5.243362831858407, + "firstSeen": 1734893382, + "uid": 429139, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 125270 + }, + "flags": 1099511628809 + }, + ... + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-transactions": { + "sequence": 81419, + "added": [ + { + "txid": "6229c0784bc776be22a5ee84e0e3d9b8f9e17843f079a8444b03bdc98b77d229", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "b4b324e3bff7ee0a7e664e8c03df1fe3a0bd53e5685ea6b10abb5f89ba1b2ead", + "vout": 5, + "prevout": { + "scriptpubkey": "76a914b54afb58f0faa9d1bde2ed755bc56ef1e4a4e24188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 b54afb58f0faa9d1bde2ed755bc56ef1e4a4e241 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1HXb8YtsgBhFWdYezjd6bt7Dw4UGKyZo54", + "value": 17000 + }, + "scriptsig": "4830450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a83649404012103cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "scriptsig_asm": "OP_PUSHBYTES_72 30450221008e9b91aae7b4705841c97dc99d6ab233f10ff9b97d7c139be08634d2f0f5f66f02205d67eae8c830ed0979e169403d13c0f43efd78edbb9a344390245f5a8364940401 OP_PUSHBYTES_33 03cf9fad8b202384de9ef010129a62b8249920a6205fe53cc0efbea9eb0db595e7", + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91401603bd82a5d5a6e8c6df5d9ae662b9fc5db60f288ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 01603bd82a5d5a6e8c6df5d9ae662b9fc5db60f2 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "18GxdcLgNtRUc8v5TNJtPnvoi8jMVWxvb", + "value": 10419 + }, + { + "scriptpubkey": "76a914338ad842d236486627834bf9f5e182c7a8aa937188ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 338ad842d236486627834bf9f5e182c7a8aa9371 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "15hXntT6oUKNhtk4FWvuGPQJDX47wpbAaa", + "value": 5396 + } + ], + "size": 226, + "weight": 904, + "sigops": 8, + "fee": 1185, + "status": { + "confirmed": false + }, + "order": 701659019, + "vsize": 226, + "adjustedVsize": 226, + "feePerVsize": 5.243362831858407, + "adjustedFeePerVsize": 5.243362831858407, + "effectiveFeePerVsize": 5.243362831858407, + "firstSeen": 1734893382, + "uid": 429139, + "inputs": [], + "cpfpDirty": false, + "ancestors": [], + "descendants": [], + "bestDescendant": null, + "position": { + "block": 0, + "vsize": 125270 + }, + "flags": 1099511628809 + }, + ... + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + } + } + }, + { + type: "endpoint", + category: "mempool", + fragment: "track-mempool-txids", + title: "Track Mempool Txids", + description: { + default: "Low-bandwith substitute to the above command track-mempool: subscribe to new mempool events, such as new transactions entering the mempool, but only transaction IDs are returned to save bandwith. Available fields: added, removed, mined, replaced." + }, + payload: '{ "track-mempool-txids": true }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-txids": { + "sequence": 79919, + "added": [ + "4bbb648ab194aaaf9188bccc6efcdcbb59c8485115a7384972c8287782206a0f", + "f7883f3784829d1e741e696bdceec488eeb53fe0b69b0eca574ac9f2e7e8e117", + "784e8e3b182c29798660bf42befb5c6479148c7d90c0d6eea032b89418e7cc3b", + "d3920a7be05269d859bd89b08a6546dc6d6dd523dbc5f7b62b9c0c5eedc43292", + "de6078d584cb5f4a27c3f0bb3d8bbb16b3d5f8303237391f390d0ee9e84d0099", + "39fcbd6e0ec0ad49405f19c72bb033f578147181b77dbe47044f80b0b7604ab5", + "47ed060004fab3fb5fa4885008aa2cadbe3335655f1303231abfe89b4b0c9bd9" + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-txids": { + "sequence": 79919, + "added": [ + "4bbb648ab194aaaf9188bccc6efcdcbb59c8485115a7384972c8287782206a0f", + "f7883f3784829d1e741e696bdceec488eeb53fe0b69b0eca574ac9f2e7e8e117", + "784e8e3b182c29798660bf42befb5c6479148c7d90c0d6eea032b89418e7cc3b", + "d3920a7be05269d859bd89b08a6546dc6d6dd523dbc5f7b62b9c0c5eedc43292", + "de6078d584cb5f4a27c3f0bb3d8bbb16b3d5f8303237391f390d0ee9e84d0099", + "39fcbd6e0ec0ad49405f19c72bb033f578147181b77dbe47044f80b0b7604ab5", + "47ed060004fab3fb5fa4885008aa2cadbe3335655f1303231abfe89b4b0c9bd9" + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-txids": { + "sequence": 79919, + "added": [ + "4bbb648ab194aaaf9188bccc6efcdcbb59c8485115a7384972c8287782206a0f", + "f7883f3784829d1e741e696bdceec488eeb53fe0b69b0eca574ac9f2e7e8e117", + "784e8e3b182c29798660bf42befb5c6479148c7d90c0d6eea032b89418e7cc3b", + "d3920a7be05269d859bd89b08a6546dc6d6dd523dbc5f7b62b9c0c5eedc43292", + "de6078d584cb5f4a27c3f0bb3d8bbb16b3d5f8303237391f390d0ee9e84d0099", + "39fcbd6e0ec0ad49405f19c72bb033f578147181b77dbe47044f80b0b7604ab5", + "47ed060004fab3fb5fa4885008aa2cadbe3335655f1303231abfe89b4b0c9bd9" + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "mempool-txids": { + "sequence": 79919, + "added": [ + "4bbb648ab194aaaf9188bccc6efcdcbb59c8485115a7384972c8287782206a0f", + "f7883f3784829d1e741e696bdceec488eeb53fe0b69b0eca574ac9f2e7e8e117", + "784e8e3b182c29798660bf42befb5c6479148c7d90c0d6eea032b89418e7cc3b", + "d3920a7be05269d859bd89b08a6546dc6d6dd523dbc5f7b62b9c0c5eedc43292", + "de6078d584cb5f4a27c3f0bb3d8bbb16b3d5f8303237391f390d0ee9e84d0099", + "39fcbd6e0ec0ad49405f19c72bb033f578147181b77dbe47044f80b0b7604ab5", + "47ed060004fab3fb5fa4885008aa2cadbe3335655f1303231abfe89b4b0c9bd9" + ], + "removed": [], + "mined": [], + "replaced": [] + } +}` + }, + } + } + }, + { + type: "endpoint", + category: "mempool", + fragment: "track-mempool-block", + title: "Track Mempool Block", + description: { + default: "Subscribe to live mempool projected block template, index 0 being the first mempool block.
A full set of stripped transactions in that block is returned when the subscription starts, and deltas (removed and added transactions) are then sent every time the mempool changes." + }, + payload: '{ "track-mempool-block": 0 }', + showConditions: bitcoinNetworks.concat(liquidNetworks), + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "projected-block-transactions": { + "index": 0, + "sequence": 80270, + "delta": { + "added": [ + [ + "172b34fb099d80f61b65d1c107c4f25665c8f50e30c1371b2e6fbced62991d58", + 2000, + 171.25, + 5942725, + 11.68, + 1099511631877, + 1734881537 + ], + ... + ], + "removed": [ + "956a6eee382214631c3299e0410565e05fbd6328c89fa746efab6371705aca2a", + ... + ], + "changed": [] + } + } +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "projected-block-transactions": { + "index": 0, + "sequence": 80270, + "delta": { + "added": [ + [ + "172b34fb099d80f61b65d1c107c4f25665c8f50e30c1371b2e6fbced62991d58", + 2000, + 171.25, + 5942725, + 11.68, + 1099511631877, + 1734881537 + ], + ... + ], + "removed": [ + "956a6eee382214631c3299e0410565e05fbd6328c89fa746efab6371705aca2a", + ... + ], + "changed": [] + } + } +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "projected-block-transactions": { + "index": 0, + "sequence": 80270, + "delta": { + "added": [ + [ + "172b34fb099d80f61b65d1c107c4f25665c8f50e30c1371b2e6fbced62991d58", + 2000, + 171.25, + 5942725, + 11.68, + 1099511631877, + 1734881537 + ], + ... + ], + "removed": [ + "956a6eee382214631c3299e0410565e05fbd6328c89fa746efab6371705aca2a", + ... + ], + "changed": [] + } + } +}` + }, + codeSampleLiquid: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "projected-block-transactions": { + "index": 0, + "sequence": 80270, + "delta": { + "added": [ + [ + "172b34fb099d80f61b65d1c107c4f25665c8f50e30c1371b2e6fbced62991d58", + 2000, + 171.25, + 5942725, + 11.68, + 1099511631877, + 1734881537 + ], + ... + ], + "removed": [ + "956a6eee382214631c3299e0410565e05fbd6328c89fa746efab6371705aca2a", + ... + ], + "changed": [] + } + } +}` + }, + } + } + }, + { + type: "endpoint", + category: "mempool", + fragment: "track-rbf", + title: "Track Mempool RBF Transactions", + description: { + default: "Subscribe to new RBF events." + }, + payload: '{ "track-rbf": "all" }', + showConditions: bitcoinNetworks, + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "cc6cb210f7ec32660fe4d46984ef64b64143fb02dc7ed70578c32b5f338ef6d6", + "fee": 8280, + "vsize": 204, + "value": 156397, + "rate": 10, + "time": 1734876576, + "rbf": true, + "fullRbf": false + }, + "time": 1734876576, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "4e94c23e075cf9c2b4ccaf32e3652b8b1bfecca6726390ccab821417f23b0876", + "fee": 4956, + "vsize": 204, + "value": 159721, + "rate": 9, + "time": 1734876204, + "rbf": true, + "fullRbf": false + }, + "time": 1734876204, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "9624fe4f9a183dcea2e8c6b640394eecaec37363aec883a64358f6953fba3145", + "fee": 1632, + "vsize": 204, + "value": 163045, + "rate": 8, + "time": 1734876081, + "rbf": true + }, + "time": 1734876081, + "interval": 123, + "fullRbf": false, + "replaces": [] + } + ], + "interval": 372 + } + ] + }, + ... + ] +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "cc6cb210f7ec32660fe4d46984ef64b64143fb02dc7ed70578c32b5f338ef6d6", + "fee": 8280, + "vsize": 204, + "value": 156397, + "rate": 10, + "time": 1734876576, + "rbf": true, + "fullRbf": false + }, + "time": 1734876576, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "4e94c23e075cf9c2b4ccaf32e3652b8b1bfecca6726390ccab821417f23b0876", + "fee": 4956, + "vsize": 204, + "value": 159721, + "rate": 9, + "time": 1734876204, + "rbf": true, + "fullRbf": false + }, + "time": 1734876204, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "9624fe4f9a183dcea2e8c6b640394eecaec37363aec883a64358f6953fba3145", + "fee": 1632, + "vsize": 204, + "value": 163045, + "rate": 8, + "time": 1734876081, + "rbf": true + }, + "time": 1734876081, + "interval": 123, + "fullRbf": false, + "replaces": [] + } + ], + "interval": 372 + } + ] + }, + ... + ] +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "cc6cb210f7ec32660fe4d46984ef64b64143fb02dc7ed70578c32b5f338ef6d6", + "fee": 8280, + "vsize": 204, + "value": 156397, + "rate": 10, + "time": 1734876576, + "rbf": true, + "fullRbf": false + }, + "time": 1734876576, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "4e94c23e075cf9c2b4ccaf32e3652b8b1bfecca6726390ccab821417f23b0876", + "fee": 4956, + "vsize": 204, + "value": 159721, + "rate": 9, + "time": 1734876204, + "rbf": true, + "fullRbf": false + }, + "time": 1734876204, + "fullRbf": false, + "replaces": [ + { + "tx": { + "txid": "9624fe4f9a183dcea2e8c6b640394eecaec37363aec883a64358f6953fba3145", + "fee": 1632, + "vsize": 204, + "value": 163045, + "rate": 8, + "time": 1734876081, + "rbf": true + }, + "time": 1734876081, + "interval": 123, + "fullRbf": false, + "replaces": [] + } + ], + "interval": 372 + } + ] + }, + ... + ] +}` + }, + codeSampleLiquid: emptyCodeSample + } + } + }, + { + type: "endpoint", + category: "mempool", + fragment: "track-full-rbf", + title: "Track Mempool Full RBF Transactions", + description: { + default: "Subscribe to new Full RBF events." + }, + payload: '{ "track-rbf": "fullRbf" }', + showConditions: bitcoinNetworks, + showJsExamples: false, + codeExample: { + default: { + codeTemplate: { + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "ed9e1ec0e1635d465ee95c8872efff367d420fc2c4e624bada2c6e6e6c8e0629", + "fee": 4123, + "vsize": 587.75, + "value": 25545, + "rate": 7.014887282007656, + "time": 1734876941, + "rbf": false, + "fullRbf": true + }, + "time": 1734876941, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "495ad5d39d44286e99bc45d104605407325cd4790f842dc3287fbfdda8ee5795", + "fee": 1178, + "vsize": 587.25, + "value": 28490, + "rate": 2.0059599829714774, + "time": 1734853572, + "rbf": false, + "fullRbf": true + }, + "time": 1734853572, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "189751a7560a6c39deb9a93db2a27374842c646268d0007ba52aefa189833afa", + "fee": 589, + "vsize": 587.25, + "value": 29079, + "rate": 1.0029799914857387, + "time": 1734781955, + "rbf": false + }, + "time": 1734781955, + "interval": 71617, + "fullRbf": true, + "replaces": [] + } + ], + "interval": 23369 + } + ] + }, + ... + ] +}` + }, + codeSampleTestnet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "ed9e1ec0e1635d465ee95c8872efff367d420fc2c4e624bada2c6e6e6c8e0629", + "fee": 4123, + "vsize": 587.75, + "value": 25545, + "rate": 7.014887282007656, + "time": 1734876941, + "rbf": false, + "fullRbf": true + }, + "time": 1734876941, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "495ad5d39d44286e99bc45d104605407325cd4790f842dc3287fbfdda8ee5795", + "fee": 1178, + "vsize": 587.25, + "value": 28490, + "rate": 2.0059599829714774, + "time": 1734853572, + "rbf": false, + "fullRbf": true + }, + "time": 1734853572, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "189751a7560a6c39deb9a93db2a27374842c646268d0007ba52aefa189833afa", + "fee": 589, + "vsize": 587.25, + "value": 29079, + "rate": 1.0029799914857387, + "time": 1734781955, + "rbf": false + }, + "time": 1734781955, + "interval": 71617, + "fullRbf": true, + "replaces": [] + } + ], + "interval": 23369 + } + ] + }, + ... + ] +}` + }, + codeSampleSignet: { + esModule: [], + commonJS: [], + curl: [], + response: `{ + "rbfLatest": [ + { + "tx": { + "txid": "ed9e1ec0e1635d465ee95c8872efff367d420fc2c4e624bada2c6e6e6c8e0629", + "fee": 4123, + "vsize": 587.75, + "value": 25545, + "rate": 7.014887282007656, + "time": 1734876941, + "rbf": false, + "fullRbf": true + }, + "time": 1734876941, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "495ad5d39d44286e99bc45d104605407325cd4790f842dc3287fbfdda8ee5795", + "fee": 1178, + "vsize": 587.25, + "value": 28490, + "rate": 2.0059599829714774, + "time": 1734853572, + "rbf": false, + "fullRbf": true + }, + "time": 1734853572, + "fullRbf": true, + "replaces": [ + { + "tx": { + "txid": "189751a7560a6c39deb9a93db2a27374842c646268d0007ba52aefa189833afa", + "fee": 589, + "vsize": 587.25, + "value": 29079, + "rate": 1.0029799914857387, + "time": 1734781955, + "rbf": false + }, + "time": 1734781955, + "interval": 71617, + "fullRbf": true, + "replaces": [] + } + ], + "interval": 23369 + } + ] + }, + ... + ] +}` + }, + codeSampleLiquid: emptyCodeSample + } + } + }, + +]; export const restApiDocsData = [ { diff --git a/frontend/src/app/docs/api-docs/api-docs-nav.component.ts b/frontend/src/app/docs/api-docs/api-docs-nav.component.ts index 11e39b518..dd19d0b4f 100644 --- a/frontend/src/app/docs/api-docs/api-docs-nav.component.ts +++ b/frontend/src/app/docs/api-docs/api-docs-nav.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { Env, StateService } from '@app/services/state.service'; -import { restApiDocsData } from '@app/docs/api-docs/api-docs-data'; +import { restApiDocsData, wsApiDocsData } from '@app/docs/api-docs/api-docs-data'; import { faqData } from '@app/docs/api-docs/api-docs-data'; @Component({ @@ -28,6 +28,8 @@ export class ApiDocsNavComponent implements OnInit { this.auditEnabled = this.env.AUDIT; if (this.whichTab === 'rest') { this.tabData = restApiDocsData; + } else if (this.whichTab === 'websocket') { + this.tabData = wsApiDocsData; } else if (this.whichTab === 'faq') { this.tabData = faqData; } diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index 38b351e37..75e37a3bd 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -108,18 +108,43 @@
-
-
-
-
-
Endpoint
- {{ wrapUrl(network.val, wsDocs, true) }} +
+ +
+ +
+ +
+ +
+

Get higher API limits with Mempool Enterprise®

+ -
-
Description
-
Default push: {{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }} to express what you want pushed. Available: blocks, mempool-blocks, live-2h-chart, and stats.

Push transactions related to address: {{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }} to receive all new transactions containing that address as input or output. Returns an array of transactions. address-transactions for new mempool transactions, and block-transactions for new block confirmed transactions.
+
+ +

Below is a reference for the {{ network.val === '' ? 'Bitcoin' : network.val.charAt(0).toUpperCase() + network.val.slice(1) }} Websocket service running at {{ websocketUrl(network.val) }}.

+

Note that usage limits apply to our WebSocket API. Consider an enterprise sponsorship if you need higher API limits, such as higher tracking limits.

+ +
+
+

{{ item.title }}

+
+ {{ item.title }} {{ item.category }} +
+
+
Description
+
+
+
+
Payload
+
+
+ +
+
-
diff --git a/frontend/src/app/docs/api-docs/api-docs.component.scss b/frontend/src/app/docs/api-docs/api-docs.component.scss index ce8c37121..0d5ff93f1 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.scss +++ b/frontend/src/app/docs/api-docs/api-docs.component.scss @@ -470,3 +470,21 @@ dd { margin-left: 1em; } } + +code { + background-color: var(--bg); + font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New; +} + +pre { + display: block; + font-size: 87.5%; + color: #f18920; + background-color: var(--bg); + padding: 30px; + code{ + background-color: transparent; + white-space: break-spaces; + word-break: break-all; + } +} \ No newline at end of file diff --git a/frontend/src/app/docs/api-docs/api-docs.component.ts b/frontend/src/app/docs/api-docs/api-docs.component.ts index 0298fc9f3..75f71bbf5 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.ts +++ b/frontend/src/app/docs/api-docs/api-docs.component.ts @@ -145,7 +145,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit { if (document.getElementById( targetId + "-tab-header" )) { tabHeaderHeight = document.getElementById( targetId + "-tab-header" ).scrollHeight; } - if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) ) && targetId ) { + if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) || ( this.whichTab === 'websocket' ) ) && targetId ) { const endpointContainerEl = document.querySelector( "#" + targetId ); const endpointContentEl = document.querySelector( "#" + targetId + " .endpoint-content" ); const endPointContentElHeight = endpointContentEl.clientHeight; @@ -207,13 +207,29 @@ export class ApiDocsComponent implements OnInit, AfterViewInit { text = text.replace('%{' + indexNumber + '}', curlText); } - if (websocket) { - const wsHostname = this.hostname.replace('https://', 'wss://'); - wsHostname.replace('http://', 'ws://'); - return `${wsHostname}${curlNetwork}${text}`; - } return `${this.hostname}${curlNetwork}${text}`; } + websocketUrl(network: string) { + let curlNetwork = ''; + if (this.env.BASE_MODULE === 'mempool') { + if (!['', 'mainnet'].includes(network)) { + curlNetwork = `/${network}`; + } + } else if (this.env.BASE_MODULE === 'liquid') { + if (!['', 'liquid'].includes(network)) { + curlNetwork = `/${network}`; + } + } + + if (network === this.env.ROOT_NETWORK) { + curlNetwork = ''; + } + + let wsHostname = this.hostname.replace('https://', 'wss://'); + wsHostname = wsHostname.replace('http://', 'ws://'); + return `${wsHostname}${curlNetwork}/api/v1/ws`; + } + } diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 5a707d889..aa2a05a2f 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -32,6 +32,8 @@ export interface Transaction { price?: Price; sigops?: number; flags?: bigint; + largeInput?: boolean; + largeOutput?: boolean; } export interface TransactionChannels { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index b39f8e0d3..05f0855a9 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -1,4 +1,4 @@ -import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface"; +import { AddressTxSummary, Block, ChainStats } from "./electrs.interface"; export interface OptimizedMempoolStats { added: number; @@ -412,13 +412,13 @@ export interface Acceleration { feeDelta: number; blockHash: string; blockHeight: number; - acceleratedFeeRate?: number; boost?: number; bidBoost?: number; boostCost?: number; boostRate?: number; minedByPoolUniqueId?: number; + canceled?: number; } export interface AccelerationHistoryParams { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index d61610a2e..9281f0fc7 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -21,6 +21,8 @@ export interface WebsocketResponse { rbfInfo?: RbfTree; rbfLatest?: RbfTree[]; rbfLatestSummary?: ReplacementInfo[]; + stratumJob?: StratumJob; + stratumJobs?: Record; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; @@ -37,6 +39,7 @@ export interface WebsocketResponse { 'track-rbf-summary'?: boolean; 'track-accelerations'?: boolean; 'track-wallet'?: string; + 'track-stratum'?: string | number; 'watch-mempool'?: boolean; 'refresh-blocks'?: boolean; } @@ -150,3 +153,24 @@ export interface HealthCheckHost { electrs?: string; } } + +export interface StratumJob { + pool: number; + height: number; + coinbase: string; + scriptsig: string; + reward: number; + jobId: string; + extraNonce: string; + extraNonce2Size: number; + prevHash: string; + coinbase1: string; + coinbase2: string; + merkleBranches: string[]; + version: string; + bits: string; + time: string; + timestamp: number; + cleanJobs: boolean; + received: number; +} diff --git a/frontend/src/app/lightning/channel/channel-preview.component.html b/frontend/src/app/lightning/channel/channel-preview.component.html index 108fe2e95..4d71bcef0 100644 --- a/frontend/src/app/lightning/channel/channel-preview.component.html +++ b/frontend/src/app/lightning/channel/channel-preview.component.html @@ -21,7 +21,7 @@
Created{{ channel.created | date:'yyyy-MM-dd HH:mm' }}
Capacity
- ‎{{ channel.closing_date | date:'yyyy-MM-dd HH:mm' }} + diff --git a/frontend/src/app/liquid/liquid-master-page.module.ts b/frontend/src/app/liquid/liquid-master-page.module.ts index 17c2c8c41..d90643b4d 100644 --- a/frontend/src/app/liquid/liquid-master-page.module.ts +++ b/frontend/src/app/liquid/liquid-master-page.module.ts @@ -142,12 +142,12 @@ const routes: Routes = [ if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) { routes[0].children.push({ - path: 'nodes', + path: 'monitoring', data: { networks: ['bitcoin', 'liquid'] }, component: ServerHealthComponent }); routes[0].children.push({ - path: 'network', + path: 'nodes', data: { networks: ['bitcoin', 'liquid'] }, component: ServerStatusComponent }); diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index 2ee2e0bd8..f0af944cc 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -10,9 +10,10 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr import { CalculatorComponent } from '@components/calculator/calculator.component'; import { BlocksList } from '@components/blocks-list/blocks-list.component'; import { RbfList } from '@components/rbf-list/rbf-list.component'; +import { StratumList } from '@components/stratum/stratum-list/stratum-list.component'; import { ServerHealthComponent } from '@components/server-health/server-health.component'; import { ServerStatusComponent } from '@components/server-health/server-status.component'; -import { FaucetComponent } from '@components/faucet/faucet.component' +import { FaucetComponent } from '@components/faucet/faucet.component'; const browserWindow = window || {}; // @ts-ignore @@ -56,6 +57,16 @@ const routes: Routes = [ path: 'rbf', component: RbfList, }, + ...(browserWindowEnv.STRATUM_ENABLED ? [{ + path: 'stratum', + component: StartComponent, + children: [ + { + path: '', + component: StratumList, + } + ] + }] : []), { path: 'terms-of-service', loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule), diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index db910779e..319bf8ef9 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -58,6 +58,7 @@ export class AuthServiceMempool { setAuth(auth: any) { if (!auth) { localStorage.removeItem('auth'); + localStorage.removeItem('authenticatorStatus'); } else { localStorage.setItem('auth', JSON.stringify(auth)); } diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 3cd5b5abd..6e9697f49 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -142,12 +142,16 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); } - getAddressesTransactions$(addresses: string[], txid?: string): Observable { + getAddressesTransactions$(addresses: string[], txid?: string): Observable { let params = new HttpParams(); if (txid) { params = params.append('after_txid', txid); } - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs?addresses=${addresses.join(',')}`, { params }); + return this.httpClient.post( + this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs', + addresses, + { params } + ); } getAddressSummary$(address: string, txid?: string): Observable { @@ -163,7 +167,7 @@ export class ElectrsApiService { if (txid) { params = params.append('after_txid', txid); } - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs/summary?addresses=${addresses.join(',')}`, { params }); + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs/summary', addresses, { params }); } getScriptHashTransactions$(script: string, txid?: string): Observable { @@ -182,7 +186,7 @@ export class ElectrsApiService { params = params.append('after_txid', txid); } return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( - switchMap(scriptHashes => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })), + switchMap(scriptHashes => this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs', scriptHashes, { params })), ); } @@ -212,7 +216,7 @@ export class ElectrsApiService { params = params.append('after_txid', txid); } return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( - switchMap(scriptHashes => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })), + switchMap(scriptHashes => this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs/summary', scriptHashes, { params })), ); } diff --git a/frontend/src/app/services/eta.service.ts b/frontend/src/app/services/eta.service.ts index cf7719327..8a2e2dc24 100644 --- a/frontend/src/app/services/eta.service.ts +++ b/frontend/src/app/services/eta.service.ts @@ -55,7 +55,7 @@ export class EtaService { return { hashratePercentage: acceleratingHashrateFraction * 100, - ETA: Date.now() + da.timeAvg * mempoolPosition.block, + ETA: Date.now() + da.adjustedTimeAvg * mempoolPosition.block, acceleratedETA: this.calculateETAFromShares([ { block: mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) }, { block: 0, hashrateShare: acceleratingHashrateFraction }, @@ -216,7 +216,7 @@ export class EtaService { } // at max depth, the transaction is guaranteed to be mined in the next block if it hasn't already Q += ((max + 1) * (1-tailProb)); - const eta = da.timeAvg * Q; // T x Q + const eta = da.adjustedTimeAvg * Q; // T x Q return { now, diff --git a/frontend/src/app/services/mining.service.ts b/frontend/src/app/services/mining.service.ts index 760ce93cb..a181ef771 100644 --- a/frontend/src/app/services/mining.service.ts +++ b/frontend/src/app/services/mining.service.ts @@ -64,8 +64,8 @@ export class MiningService { ); } } - - /** + + /** * Get names and slugs of all pools */ public getPools(): Observable { @@ -75,7 +75,6 @@ export class MiningService { return this.poolsData; }) ); - } /** * Set the hashrate power of ten we want to display diff --git a/frontend/src/app/services/services-api.service.ts b/frontend/src/app/services/services-api.service.ts index 2ecfe06ff..59dc92358 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -18,7 +18,6 @@ export interface IUser { subscription_tag: string; status: 'pending' | 'verified' | 'disabled'; features: string | null; - fullName: string | null; countryCode: string | null; imageMd5: string; ogRank: number | null; @@ -143,8 +142,12 @@ export class ServicesApiServices { return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); } - accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, userApprovedUSD: number) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); + accelerateWithGooglePay$(txInput: string, token: string, verificationToken: string, cardTag: string, referenceId: string, userApprovedUSD: number, userChallenged: boolean) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD, userChallenged: userChallenged }); + } + + accelerateWithCardOnFile$(txInput: string, token: string, verificationToken: string, referenceId: string, userApprovedUSD: number, userChallenged: boolean) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cardOnFile`, { txInput: txInput, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD, userChallenged: userChallenged }); } getAccelerations$(): Observable { diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 2feb266d1..7f8f81744 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; -import { AddressTxSummary, Transaction } from '@interfaces/electrs.interface'; -import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '@interfaces/websocket.interface'; +import { Transaction } from '@interfaces/electrs.interface'; +import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface'; import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; @@ -81,6 +81,7 @@ export interface Env { ADDITIONAL_CURRENCIES: boolean; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string; + STRATUM_ENABLED: boolean; SERVICES_API?: string; customize?: Customization; PROD_DOMAINS: string[]; @@ -123,6 +124,7 @@ const defaultEnv: Env = { 'ACCELERATOR_BUTTON': true, 'PUBLIC_ACCELERATIONS': false, 'ADDITIONAL_CURRENCIES': false, + 'STRATUM_ENABLED': false, 'SERVICES_API': 'https://mempool.space/api/v1/services', 'PROD_DOMAINS': [], }; @@ -159,6 +161,8 @@ export class StateService { liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>; accelerations$ = new Subject(); liveAccelerations$: Observable; + stratumJobUpdate$ = new Subject<{ state: Record } | { job: StratumJob }>(); + stratumJobs$ = new BehaviorSubject>({}); txConfirmed$ = new Subject<[string, BlockExtended]>(); txReplaced$ = new Subject(); txRbfInfo$ = new Subject(); @@ -186,6 +190,7 @@ export class StateService { live2Chart$ = new Subject(); viewAmountMode$: BehaviorSubject<'btc' | 'sats' | 'fiat'>; + timezone$: BehaviorSubject; connectionState$ = new BehaviorSubject<0 | 1 | 2>(2); isTabHidden$: Observable; @@ -302,6 +307,24 @@ export class StateService { map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added)) ); + this.stratumJobUpdate$.pipe( + scan((acc: Record, update: { state: Record } | { job: StratumJob }) => { + if ('state' in update) { + // Replace the entire state + return update.state; + } else { + // Update or create a single job entry + return { + ...acc, + [update.job.pool]: update.job + }; + } + }, {}), + shareReplay(1) + ).subscribe(val => { + this.stratumJobs$.next(val); + }); + this.networkChanged$.subscribe((network) => { this.transactions$ = new BehaviorSubject(null); this.blocksSubject$.next([]); @@ -347,6 +370,9 @@ export class StateService { const viewAmountModePreference = this.storageService.getValue('view-amount-mode') as 'btc' | 'sats' | 'fiat'; this.viewAmountMode$ = new BehaviorSubject<'btc' | 'sats' | 'fiat'>(viewAmountModePreference || 'btc'); + const timezonePreference = this.storageService.getValue('timezone-preference'); + this.timezone$ = new BehaviorSubject(timezonePreference || 'local'); + this.backend$.subscribe(backend => { this.backend = backend; }); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 0f5368244..b82b32dd5 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -36,6 +36,7 @@ export class WebsocketService { private isTrackingAccelerations: boolean = false; private isTrackingWallet: boolean = false; private trackingWalletName: string; + private isTrackingStratum: string | number | false = false; private trackingMempoolBlock: number; private trackingMempoolBlockNetwork: string; private stoppingTrackMempoolBlock: any | null = null; @@ -143,6 +144,9 @@ export class WebsocketService { if (this.isTrackingWallet) { this.startTrackingWallet(this.trackingWalletName); } + if (this.isTrackingStratum !== false) { + this.startTrackStratum(this.isTrackingStratum); + } this.stateService.connectionState$.next(2); } @@ -289,6 +293,18 @@ export class WebsocketService { } } + startTrackStratum(pool: number | string) { + this.websocketSubject.next({ 'track-stratum': pool }); + this.isTrackingStratum = pool; + } + + stopTrackStratum() { + if (this.isTrackingStratum) { + this.websocketSubject.next({ 'track-stratum': null }); + this.isTrackingStratum = false; + } + } + fetchStatistics(historicalDate: string) { this.websocketSubject.next({ historicalDate }); } @@ -512,6 +528,14 @@ export class WebsocketService { this.stateService.previousRetarget$.next(response.previousRetarget); } + if (response.stratumJobs) { + this.stateService.stratumJobUpdate$.next({ state: response.stratumJobs }); + } + + if (response.stratumJob) { + this.stateService.stratumJobUpdate$.next({ job: response.stratumJob }); + } + if (response['tomahawk']) { this.stateService.serverHealth$.next(response['tomahawk']); } diff --git a/frontend/src/app/shared/components/confirmations/confirmations.component.html b/frontend/src/app/shared/components/confirmations/confirmations.component.html index 4ad3cb33a..282979824 100644 --- a/frontend/src/app/shared/components/confirmations/confirmations.component.html +++ b/frontend/src/app/shared/components/confirmations/confirmations.component.html @@ -11,9 +11,9 @@ - + - + \ No newline at end of file diff --git a/frontend/src/app/shared/components/confirmations/confirmations.component.ts b/frontend/src/app/shared/components/confirmations/confirmations.component.ts index 624c58278..d54f80b10 100644 --- a/frontend/src/app/shared/components/confirmations/confirmations.component.ts +++ b/frontend/src/app/shared/components/confirmations/confirmations.component.ts @@ -12,6 +12,7 @@ export class ConfirmationsComponent implements OnChanges { @Input() height: number; @Input() replaced: boolean = false; @Input() removed: boolean = false; + @Input() cached: boolean = false; @Input() hideUnconfirmed: boolean = false; @Input() buttonClass: string = ''; diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index e327a11de..24e5c73ae 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -30,7 +30,7 @@
- +
diff --git a/frontend/src/app/shared/components/mempool-error/mempool-error.component.ts b/frontend/src/app/shared/components/mempool-error/mempool-error.component.ts index f105a9655..7eb9bb5eb 100644 --- a/frontend/src/app/shared/components/mempool-error/mempool-error.component.ts +++ b/frontend/src/app/shared/components/mempool-error/mempool-error.component.ts @@ -26,6 +26,7 @@ export const MempoolErrors = { 'unauthorized': `You are not authorized to do this`, 'faucet_too_soon': `You cannot request any more coins right now. Try again later.`, 'faucet_not_available': `The faucet is not available right now. Try again later.`, + 'faucet_not_available_no_utxo': `The faucet is not available right now. Please try again once a new block has been mined.`, 'faucet_maximum_reached': `You are not allowed to request more coins`, 'faucet_address_not_allowed': `You cannot use this address`, 'faucet_below_minimum': `Requested amount is too small`, diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.html b/frontend/src/app/shared/components/timestamp/timestamp.component.html index 7b77cb1a3..097867b42 100644 --- a/frontend/src/app/shared/components/timestamp/timestamp.component.html +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.html @@ -1,6 +1,6 @@ - - ‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }} + ‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' : (stateService.timezone$ | async) }}
()
diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.ts b/frontend/src/app/shared/components/timestamp/timestamp.component.ts index aace6efbf..5ca6a750b 100644 --- a/frontend/src/app/shared/components/timestamp/timestamp.component.ts +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { StateService } from '@app/services/state.service'; @Component({ selector: 'app-timestamp', @@ -16,6 +17,10 @@ export class TimestampComponent implements OnChanges { seconds: number | undefined = undefined; + constructor( + public stateService: StateService, + ) { } + ngOnChanges(): void { if (this.unixTime) { this.seconds = this.unixTime; diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index a855f11b5..76184113f 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,10 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, - faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline, faCircleXmark, faCalendarCheck } from '@fortawesome/free-solid-svg-icons'; + faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, + faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, + faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline, + faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MenuComponent } from '@components/menu/menu.component'; import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component'; @@ -36,6 +39,7 @@ import { FiatSelectorComponent } from '@components/fiat-selector/fiat-selector.c import { RateUnitSelectorComponent } from '@components/rate-unit-selector/rate-unit-selector.component'; import { ThemeSelectorComponent } from '@components/theme-selector/theme-selector.component'; import { AmountSelectorComponent } from '@components/amount-selector/amount-selector.component'; +import { TimezoneSelectorComponent } from '@components/timezone-selector/timezone-selector.component'; import { BrowserOnlyDirective } from '@app/shared/directives/browser-only.directive'; import { ServerOnlyDirective } from '@app/shared/directives/server-only.directive'; import { ColoredPriceDirective } from '@app/shared/directives/colored-price.directive'; @@ -79,6 +83,7 @@ import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components'; import { BlocksList } from '@components/blocks-list/blocks-list.component'; import { RbfList } from '@components/rbf-list/rbf-list.component'; +import { StratumList } from '@components/stratum/stratum-list/stratum-list.component'; import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component'; import { DataCyDirective } from '@app/data-cy.directive'; import { LoadingIndicatorComponent } from '@components/loading-indicator/loading-indicator.component'; @@ -120,6 +125,7 @@ import { TwitterLogin } from '@components/twitter-login/twitter-login.component' import { BitcoinInvoiceComponent } from '@components/bitcoin-invoice/bitcoin-invoice.component'; import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/weight-directives/weight-directives'; +import { GithubLogin } from '../components/github-login.component/github-login.component'; @NgModule({ declarations: [ @@ -134,6 +140,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ ThemeSelectorComponent, RateUnitSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, NoSanitizePipe, @@ -196,6 +203,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ DifficultyAdjustmentsTable, BlocksList, RbfList, + StratumList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -235,6 +243,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ TwitterWidgetComponent, FaucetComponent, TwitterLogin, + GithubLogin, BitcoinInvoiceComponent, ], imports: [ @@ -283,6 +292,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ RateUnitSelectorComponent, ThemeSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, Hex2asciiPipe, @@ -339,6 +349,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ AmountShortenerPipe, DifficultyAdjustmentsTable, BlocksList, + StratumList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -367,6 +378,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ HttpErrorComponent, TwitterWidgetComponent, TwitterLogin, + GithubLogin, BitcoinInvoiceComponent, BitcoinsatoshisPipe, @@ -448,5 +460,9 @@ export class SharedModule { library.addIcons(faTimeline); library.addIcons(faCircleXmark); library.addIcons(faCalendarCheck); + library.addIcons(faMoneyBillTrendUp); + library.addIcons(faRobot); + library.addIcons(faShareNodes); + library.addIcons(faCreditCard); } } diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index cf26b0798..0cb682713 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -158,5 +158,9 @@ "WALLETS": { "ENABLED": true, "WALLETS": ["BITB", "3350"] + }, + "STRATUM": { + "ENABLED": true, + "API": "http://127.0.0.1:81/api/v1/stratum/ws" } } diff --git a/production/mempool-frontend-config.mainnet.json b/production/mempool-frontend-config.mainnet.json index 79acaecc5..e0cdbf030 100644 --- a/production/mempool-frontend-config.mainnet.json +++ b/production/mempool-frontend-config.mainnet.json @@ -4,8 +4,7 @@ "TESTNET4_ENABLED": true, "LIQUID_ENABLED": false, "LIQUID_TESTNET_ENABLED": false, - "BISQ_ENABLED": true, - "BISQ_SEPARATE_BACKEND": true, + "STRATUM_ENABLED": true, "SIGNET_ENABLED": true, "MEMPOOL_WEBSITE_URL": "https://mempool.space", "LIQUID_WEBSITE_URL": "https://liquid.network", diff --git a/production/nginx/location-api.conf b/production/nginx/location-api.conf index 80f513147..b337c0f5b 100644 --- a/production/nginx/location-api.conf +++ b/production/nginx/location-api.conf @@ -140,7 +140,8 @@ location @mempool-api-v1-cache-normal { proxy_cache_valid 200 2s; proxy_redirect off; - expires 2s; + # cache for 2 seconds on server, but send expires -1 so browser doesn't cache + expires -1; } location @mempool-api-v1-cache-disabled { diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 755232b50..661394cb7 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -30,6 +30,7 @@ class Server { secureHost = true; secureMempoolHost = true; canonicalHost: string; + networkName: string; seoQueueLength: number = 0; unfurlQueueLength: number = 0; @@ -41,6 +42,7 @@ class Server { this.secureHost = config.SERVER.HOST.startsWith('https'); this.secureMempoolHost = config.MEMPOOL.HTTP_HOST.startsWith('https'); this.network = config.MEMPOOL.NETWORK || 'bitcoin'; + this.networkName = networks[this.network].networkName || capitalize(this.network); let canonical; switch(config.MEMPOOL.NETWORK) { @@ -339,7 +341,7 @@ class Server { if (matchedRoute.render) { ogImageUrl = `${config.SERVER.HOST}/render/${lang || 'en'}/preview${path}`; - ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${matchedRoute.networkMode !== 'mainnet' ? capitalize(matchedRoute.networkMode) + ' ' : ''}${matchedRoute.title}`; + ogTitle = `${this.networkName} ${matchedRoute.networkMode !== 'mainnet' ? capitalize(matchedRoute.networkMode) + ' ' : ''}${matchedRoute.title}`; } else { ogTitle = networks[this.network].title; } @@ -394,7 +396,7 @@ class Server { if (matchedRoute.render) { ogImageUrl = `${config.SERVER.HOST}/render/${lang || 'en'}/preview${path}`; - ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${matchedRoute.networkMode !== 'mainnet' ? capitalize(matchedRoute.networkMode) + ' ' : ''}${matchedRoute.title}`; + ogTitle = `${this.networkName} ${matchedRoute.networkMode !== 'mainnet' ? capitalize(matchedRoute.networkMode) + ' ' : ''}${matchedRoute.title}`; } if (matchedRoute.sip) { diff --git a/unfurler/src/routes.ts b/unfurler/src/routes.ts index c6be7e129..dcea29cde 100644 --- a/unfurler/src/routes.ts +++ b/unfurler/src/routes.ts @@ -270,6 +270,7 @@ export const networks = { routes: {} // no routes supported }, onbtc: { + networkName: 'ONBTC', title: 'National Bitcoin Office of El Salvador', description: 'The National Bitcoin Office (ONBTC) of El Salvador under President @nayibbukele', fallbackImg: '/resources/onbtc/onbtc-preview.jpg', @@ -290,6 +291,7 @@ export const networks = { } }, bitb: { + networkName: 'BITB', title: 'BITB | Bitwise Bitcoin ETF', description: 'BITB provides low-cost access to bitcoin through a professionally managed fund', fallbackImg: '/resources/bitb/bitb-preview.jpg', @@ -311,6 +313,7 @@ export const networks = { } }, meta: { + networkName: 'Metaplanet', title: 'Metaplanet Inc.', description: 'Secure the Future with Bitcoin', fallbackImg: '/resources/meta/meta-preview.png',