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/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/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..ccdc3dc7c 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -22,6 +22,11 @@ import rbfCache from '../rbf-cache'; import { calculateMempoolTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; +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) { app @@ -90,7 +95,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 +114,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 +126,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 +148,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 +196,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 +217,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 +228,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 +248,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 +316,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 +335,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 +394,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 +470,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 +512,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 +547,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 +576,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 +585,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 +594,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 +616,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 +630,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 +649,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 +661,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 +673,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 +689,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 +705,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'); } } @@ -667,7 +749,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 +759,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 +816,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 +825,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 +834,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 +851,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 +877,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 +888,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 +900,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 +912,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 +925,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 9a4b7706a..8035d92c0 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -1,12 +1,12 @@ import config from '../../config'; -import axios, { AxiosResponse, isAxiosError } from 'axios'; +import axios, { isAxiosError } from 'axios'; import http from 'http'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; import { IEsploraApi } from './esplora-api.interface'; import logger from '../../logger'; import { Common } from '../common'; import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface'; - +import os from 'os'; interface FailoverHost { host: string, rtts: number[], @@ -20,6 +20,13 @@ interface FailoverHost { preferred?: boolean, checked: boolean, lastChecked?: number, + publicDomain: string, + hashes: { + frontend?: string, + backend?: string, + electrs?: string, + lastUpdated: number, + } } class FailoverRouter { @@ -29,14 +36,21 @@ class FailoverRouter { maxHeight: number = 0; hosts: FailoverHost[]; multihost: boolean; - pollInterval: number = 60000; + gitHashInterval: number = 600000; // 10 minutes + pollInterval: number = 60000; // 1 minute pollTimer: NodeJS.Timeout | null = null; pollConnection = axios.create(); + localHostname: string = 'localhost'; requestConnection = axios.create({ httpAgent: new http.Agent({ keepAlive: true }) }); constructor() { + try { + this.localHostname = os.hostname(); + } catch (e) { + logger.warn('Failed to set local hostname, using "localhost"'); + } // setup list of hosts this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => { return { @@ -45,6 +59,10 @@ class FailoverRouter { rtts: [], rtt: Infinity, failures: 0, + publicDomain: 'https://' + this.extractPublicDomain(domain), + hashes: { + lastUpdated: 0, + }, }; }); this.activeHost = { @@ -55,6 +73,10 @@ class FailoverRouter { socket: !!config.ESPLORA.UNIX_SOCKET_PATH, preferred: true, checked: false, + publicDomain: `http://${this.localHostname}`, + hashes: { + lastUpdated: 0, + }, }; this.fallbackHost = this.activeHost; this.hosts.unshift(this.activeHost); @@ -106,6 +128,24 @@ class FailoverRouter { host.outOfSync = false; } host.unreachable = false; + + // update esplora git hash using the x-powered-by header from the height check + const poweredBy = result.headers['x-powered-by']; + if (poweredBy) { + const match = poweredBy.match(/([a-fA-F0-9]{5,40})/); + if (match && match[1]?.length) { + host.hashes.electrs = match[1]; + } + } + + // Check front and backend git hashes less often + if (Date.now() - host.hashes.lastUpdated > this.gitHashInterval) { + await Promise.all([ + this.$updateFrontendGitHash(host), + this.$updateBackendGitHash(host) + ]); + host.hashes.lastUpdated = Date.now(); + } } else { host.outOfSync = true; host.unreachable = true; @@ -202,6 +242,47 @@ class FailoverRouter { } } + // methods for retrieving git hashes by host + private async $updateFrontendGitHash(host: FailoverHost): Promise { + try { + 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) { + host.hashes.frontend = match[1]; + } + } catch (e) { + // failed to get frontend build hash - do nothing + } + } + + private async $updateBackendGitHash(host: FailoverHost): Promise { + try { + 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; + } + } catch (e) { + // failed to get backend build hash - do nothing + } + } + + // 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; @@ -381,6 +462,7 @@ class ElectrsApi implements AbstractBitcoinApi { unreachable: !!host.unreachable, checked: !!host.checked, lastChecked: host.lastChecked || 0, + hashes: host.hashes, })); } else { return []; diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index ee9df9151..dc8c7291a 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 = 83; + private static currentVersion = 94; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -710,6 +710,414 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL'); await this.updateToSchemaVersion(83); } + + // add new pools indexes + if (databaseSchemaVersion < 84 && isBitcoin === true) { + await this.$executeQuery(` + ALTER TABLE \`pools\` + ADD INDEX \`slug\` (\`slug\`), + ADD INDEX \`unique_id\` (\`unique_id\`) + `); + await this.updateToSchemaVersion(84); + } + + // lightning channels indexes + if (databaseSchemaVersion < 85 && isBitcoin === true) { + 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\`) + `); + await this.updateToSchemaVersion(85); + } + + // lightning nodes indexes + if (databaseSchemaVersion < 86 && isBitcoin === true) { + 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\`) + `); + await this.updateToSchemaVersion(86); + } + + // lightning node sockets indexes + if (databaseSchemaVersion < 87 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)'); + await this.updateToSchemaVersion(87); + } + + // lightning stats indexes + if (databaseSchemaVersion < 88 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)'); + await this.updateToSchemaVersion(88); + } + + // geo names indexes + if (databaseSchemaVersion < 89 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)'); + await this.updateToSchemaVersion(89); + } + + // hashrates indexes + if (databaseSchemaVersion < 90 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)'); + await this.updateToSchemaVersion(90); + } + + // block audits indexes + if (databaseSchemaVersion < 91 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)'); + await this.updateToSchemaVersion(91); + } + + // elements_pegs indexes + if (databaseSchemaVersion < 92 && config.MEMPOOL.NETWORK === 'liquid') { + 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\`) + `); + await this.updateToSchemaVersion(92); + } + + // federation_txos indexes + if (databaseSchemaVersion < 93 && config.MEMPOOL.NETWORK === 'liquid') { + 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\`) + `); + 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); + } } /** 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/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 6e547e653..ba4ce2ed0 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -382,7 +382,7 @@ class MempoolBlocks { const ancestors: Ancestor[] = []; const descendants: Ancestor[] = []; - let ancestor: MempoolTransactionExtended + let ancestor: MempoolTransactionExtended; for (const cluster of clusters) { for (const memberTxid of cluster) { const mempoolTx = mempool[memberTxid]; @@ -462,7 +462,7 @@ class MempoolBlocks { for (let i = 0; i < block.length; i++) { const txid = block[i]; - if (txid) { + if (txid in mempool) { mempoolTx = mempool[txid]; // save position in projected blocks mempoolTx.position = { @@ -481,6 +481,9 @@ class MempoolBlocks { mempoolTx.acceleratedAt = acceleration?.added; mempoolTx.feeDelta = acceleration?.feeDelta; for (const ancestor of mempoolTx.ancestors || []) { + if (!(ancestor.txid in mempool)) { + continue; + } if (!mempool[ancestor.txid].acceleration) { mempool[ancestor.txid].cpfpDirty = true; } @@ -688,7 +691,7 @@ class MempoolBlocks { [pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean }; } = {}; // prepare a list of accelerations in ascending order (we'll pop items off the end of the list) - const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).map(acc => { + const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).filter(acc => acc.txid in mempoolCache).map(acc => { let vsize = mempoolCache[acc.txid].vsize; for (const ancestor of mempoolCache[acc.txid].ancestors || []) { vsize += (ancestor.weight / 4); 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/prices/prices.routes.ts b/backend/src/api/prices/prices.routes.ts index b46331b73..e395fb44b 100644 --- a/backend/src/api/prices/prices.routes.ts +++ b/backend/src/api/prices/prices.routes.ts @@ -1,10 +1,15 @@ import { Application, Request, Response } from 'express'; import config from '../../config'; import pricesUpdater from '../../tasks/price-updater'; +import logger from '../../logger'; +import PricesRepository from '../../repositories/PricesRepository'; class PricesRoutes { public initRoutes(app: Application): void { - app.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this)); + app + .get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this)) + .get(config.MEMPOOL.API_URL_PREFIX + 'internal/usd-price-history', this.$getAllPrices.bind(this)) + ; } private $getCurrentPrices(req: Request, res: Response): void { @@ -14,6 +19,23 @@ class PricesRoutes { res.json(pricesUpdater.getLatestPrices()); } + + private async $getAllPrices(req: Request, res: Response): Promise { + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString()); + + try { + const usdPriceHistory = await PricesRepository.$getPricesTimesAndId(); + const responseData = usdPriceHistory.map(p => { + return { time: p.time, USD: p.USD }; + }); + res.status(200).json(responseData); + } catch (e: any) { + logger.err(`Exception ${e} in PricesRoutes::$getAllPrices. Code: ${e.code}. Message: ${e.message}`); + res.status(403).send(); + } + } } export default new PricesRoutes(); 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/acceleration.ts b/backend/src/api/services/acceleration.ts index e18bcf464..053da6e82 100644 --- a/backend/src/api/services/acceleration.ts +++ b/backend/src/api/services/acceleration.ts @@ -246,17 +246,22 @@ class AccelerationApi { this.startedWebsocketLoop = true; if (!this.ws) { this.ws = new WebSocket(this.websocketPath); - this.websocketConnected = true; + this.lastPing = 0; this.ws.on('open', () => { logger.info(`Acceleration websocket opened to ${this.websocketPath}`); + this.websocketConnected = true; this.ws?.send(JSON.stringify({ 'watch-accelerations': true })); }); this.ws.on('error', (error) => { - logger.err(`Acceleration websocket error on ${this.websocketPath}: ` + error); + let errMsg = `Acceleration websocket error on ${this.websocketPath}: ${error['code']}`; + if (error['errors']) { + errMsg += ' - ' + error['errors'].join(' - '); + } + logger.err(errMsg); this.ws = null; this.websocketConnected = false; }); @@ -285,16 +290,28 @@ class AccelerationApi { logger.debug('received pong from acceleration websocket server'); this.lastPong = Date.now(); }); - } else { - if (this.lastPing > this.lastPong && Date.now() - this.lastPing > 10000) { + } else if (this.websocketConnected) { + if (this.lastPing && this.lastPing > this.lastPong && (Date.now() - this.lastPing > 10000)) { logger.warn('No pong received within 10 seconds, terminating connection'); - this.ws.terminate(); - this.ws = null; - this.websocketConnected = false; - } else if (Date.now() - this.lastPing > 30000) { + try { + this.ws?.terminate(); + } catch (e) { + logger.warn('failed to terminate acceleration websocket connection: ' + (e instanceof Error ? e.message : e)); + } finally { + this.ws = null; + this.websocketConnected = false; + this.lastPing = 0; + } + } else if (!this.lastPing || (Date.now() - this.lastPing > 30000)) { logger.debug('sending ping to acceleration websocket server'); - this.ws.ping(); - this.lastPing = Date.now(); + if (this.ws?.readyState === WebSocket.OPEN) { + try { + this.ws?.ping(); + this.lastPing = Date.now(); + } catch (e) { + logger.warn('failed to send ping to acceleration websocket server: ' + (e instanceof Error ? e.message : e)); + } + } } } await new Promise(resolve => setTimeout(resolve, 5000)); 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/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/frontend/custom-meta-config.json b/frontend/custom-meta-config.json new file mode 100644 index 000000000..6fa46192a --- /dev/null +++ b/frontend/custom-meta-config.json @@ -0,0 +1,51 @@ +{ + "theme": "contrast", + "enterprise": "meta", + "branding": { + "name": "metaplanet", + "title": "Metaplanet", + "site_id": 21, + "header_img": "/resources/metalogo.svg", + "footer_img": "/resources/metalogo.svg" + }, + "dashboard": { + "widgets": [ + { + "component": "fees", + "mobileOrder": 4 + }, + { + "component": "walletBalance", + "mobileOrder": 1, + "props": { + "wallet": "3350" + } + }, + { + "component": "twitter", + "mobileOrder": 5, + "props": { + "handle": "Metaplanet_JP" + } + }, + { + "component": "wallet", + "mobileOrder": 2, + "props": { + "wallet": "3350", + "period": "all" + } + }, + { + "component": "blocks" + }, + { + "component": "walletTransactions", + "mobileOrder": 3, + "props": { + "wallet": "3350" + } + } + ] + } +} \ No newline at end of file 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..c59a85671 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", @@ -35,7 +35,6 @@ "domino": "^2.1.6", "echarts": "~5.5.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", @@ -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", @@ -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" }, @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 6a0d7dc12..2910b8869 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", @@ -87,7 +87,6 @@ "clipboard": "^2.0.11", "domino": "^2.1.6", "echarts": "~5.5.0", - "lightweight-charts": "~3.8.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 644d3e9d5..9debc4f11 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,4 +1,4 @@ -
+
@if (accelerateError) { @if (accelerateError.includes('Payment declined')) {
@@ -369,7 +369,7 @@

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

@@ -492,6 +492,11 @@
}
+ @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..75c6a397d 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); 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 763332ceb..480f4e601 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core'; import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs'; import { ServicesApiServices } from '@app/services/services-api.service'; -import { md5, insecureRandomUUID } from '@app/shared/common.utils'; +import { md5 } from '@app/shared/common.utils'; import { StateService } from '@app/services/state.service'; import { AudioService } from '@app/services/audio.service'; import { ETA, EtaService } from '@app/services/eta.service'; @@ -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 @@ -94,7 +96,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { auth: IAuth | null = null; // accelerator stuff - accelerationUUID: string; accelerationSubscription: Subscription; difficultySubscription: Subscription; estimateSubscription: Subscription; @@ -138,7 +139,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { private enterpriseService: EnterpriseService, ) { this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1; - this.accelerationUUID = insecureRandomUUID(); // Check if Apple Pay available // https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview @@ -156,7 +156,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; } @@ -165,11 +165,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( @@ -194,14 +194,18 @@ 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) { clearTimeout(this.timeoutTimer); @@ -243,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { closeModal(): void { this.completed.emit(true); - this.moveToStep('summary'); + this.moveToStep('summary', true); } /** @@ -387,7 +391,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerationSubscription = this.servicesApiService.accelerate$( this.tx.txid, this.userBid, - this.accelerationUUID ).subscribe({ next: () => { this.processing = false; @@ -395,7 +398,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; @@ -505,57 +508,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)}`, - this.accelerationUUID, - 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')}`, ``)); - }, 10000); - } + 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) { @@ -605,57 +626,84 @@ 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)}`, - this.accelerationUUID, - 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')}`, ``)); - }, 10000); - } + 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--; } }); } @@ -712,7 +760,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { tokenResult.token, tokenResult.details.cashAppPay.cashtag, tokenResult.details.cashAppPay.referenceId, - this.accelerationUUID, costUSD ).subscribe({ next: () => { @@ -723,7 +770,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')}`, '')); @@ -748,6 +795,32 @@ 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 */ @@ -771,7 +844,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 { diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html index ef3ace5ea..af76bbc7b 100644 --- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html @@ -1,6 +1,6 @@
- @if (!tx.status.confirmed) { + @if (!tx.status.confirmed || canceled) {
@@ -8,7 +8,7 @@
- @if (eta) { + @if (eta && !canceled) { ~ }
@@ -19,16 +19,20 @@
-
+
-
+
-
Mined
+ @if (canceled) { +
Canceled
+ } @else { +
Mined
+ }
@@ -45,9 +49,9 @@
@if (tx.status.confirmed) { -
- -
+ + } @else if (eta && canceled) { + ~ }
@@ -71,42 +75,42 @@
-
+
- @if (tx.status.confirmed) { + @if (tx.status.confirmed && !canceled) {
} @else {
}
- @if (!tx.status.confirmed) { -
+ @if (!tx.status.confirmed || canceled) { +
}
- @if (tx.status.confirmed) { + @if (tx.status.confirmed && !canceled) {
Accelerated
} -
+
@if (!tx.status.confirmed) { Accelerated{{ "" }} } @if (useAbsoluteTime) { {{ acceleratedAt * 1000 | date }} } @else { - + }
- @if (tx.status.confirmed) { + @if (tx.status.confirmed && !canceled) {
} @else {
}
- @if (tx.status.confirmed) { + @if (tx.status.confirmed && !canceled) {
} @else {
diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss index f351a0114..2bd46199a 100644 --- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss @@ -129,6 +129,9 @@ margin-left: calc(-4em + 5px); animation: goFasterLeft 0.8s infinite linear; } + &.no-animation { + animation: none; + } } &.left { diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts index 728992212..59e63d839 100644 --- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts @@ -15,6 +15,7 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges { @Input() tx: Transaction; @Input() accelerationInfo: Acceleration; @Input() eta: ETA; + @Input() canceled: boolean; now: number; accelerateRatio: number; 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 5ac288b2e..225bf1955 100644 --- a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.html @@ -4,7 +4,7 @@
-
+
@@ -21,8 +21,8 @@ - - + + } @else { @@ -217,10 +214,10 @@ } @else { @@ -247,7 +244,7 @@ @if (!isLoadingTx) { - @if ((cpfpInfo && hasEffectiveFeeRate) || accelerationInfo) { + @if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) { @if (isAcceleration) { @@ -280,7 +277,7 @@ diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts index fad607ffb..2b539c154 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts @@ -29,7 +29,6 @@ export class TransactionDetailsComponent implements OnInit { @Input() hasEffectiveFeeRate: boolean; @Input() cpfpInfo: CpfpInfo; @Input() hasCpfp: boolean; - @Input() showCpfpDetails: boolean; @Input() accelerationInfo: Acceleration; @Input() acceleratorAvailable: boolean; @Input() accelerateCtaType: string; @@ -51,7 +50,7 @@ export class TransactionDetailsComponent implements OnInit { this.accelerateClicked.emit(true); } - toggleCpfp(): void { + toggleCpfp(): void { this.toggleCpfp$.emit(); } } diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index a03288029..cce0e23eb 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" > @@ -52,7 +53,6 @@ [hasEffectiveFeeRate]="hasEffectiveFeeRate" [cpfpInfo]="cpfpInfo" [hasCpfp]="hasCpfp" - [showCpfpDetails]="showCpfpDetails" [accelerationInfo]="accelerationInfo" [replaced]="replaced" [isCached]="isCached" @@ -166,12 +166,12 @@
- +

Acceleration Timeline

- +
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index f19a5bcbd..ab71529c0 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -107,6 +107,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { pool: Pool | null; auditStatus: TxAuditStatus | null; isAcceleration: boolean = false; + accelerationCanceled: boolean = false; filters: Filter[] = []; showCpfpDetails = false; miningStats: MiningStats; @@ -239,7 +240,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { retry({ count: 2, delay: 2000 }), // Try again until we either get a valid response, or the transaction is confirmed repeat({ delay: 2000 }), - filter((transactionTimes) => transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed), + filter((transactionTimes) => transactionTimes?.[0] > 0 || this.tx.status?.confirmed), take(1), )), ) @@ -360,16 +361,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ).subscribe((accelerationHistory) => { for (const acceleration of accelerationHistory) { if (acceleration.txid === this.txId) { - if (acceleration.status === 'completed' || acceleration.status === 'completed_provisional') { - if (acceleration.pools.includes(acceleration.minedByPoolUniqueId)) { - const boostCost = acceleration.boostCost || acceleration.bidBoost; - acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize; - acceleration.boost = boostCost; - this.tx.acceleratedAt = acceleration.added; - this.accelerationInfo = acceleration; - } else { - this.tx.feeDelta = undefined; - } + if ((acceleration.status === 'completed' || acceleration.status === 'completed_provisional') && acceleration.pools.includes(acceleration.minedByPoolUniqueId)) { + const boostCost = acceleration.boostCost || acceleration.bidBoost; + acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize; + acceleration.boost = boostCost; + this.tx.acceleratedAt = acceleration.added; + this.accelerationInfo = acceleration; + } + if (acceleration.status === 'failed' || acceleration.status === 'failed_provisional') { + this.accelerationCanceled = true; + this.tx.acceleratedAt = acceleration.added; + this.accelerationInfo = acceleration; } this.waitingForAccelerationInfo = false; this.setIsAccelerated(); @@ -878,6 +880,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.tx.acceleratedBy = cpfpInfo.acceleratedBy; this.tx.acceleratedAt = cpfpInfo.acceleratedAt; this.tx.feeDelta = cpfpInfo.feeDelta; + this.accelerationCanceled = false; + this.setIsAccelerated(firstCpfp); + } else if (cpfpInfo.acceleratedAt) { // Acceleration was cancelled: reset acceleration state + this.tx.acceleratedBy = cpfpInfo.acceleratedBy; + this.tx.acceleratedAt = cpfpInfo.acceleratedAt; + this.tx.feeDelta = cpfpInfo.feeDelta; + this.accelerationCanceled = true; this.setIsAccelerated(firstCpfp); } @@ -901,7 +910,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } setIsAccelerated(initialState: boolean = false) { - this.isAcceleration = ((this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); + this.isAcceleration = + ( + (this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) || + (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id))) + ) && + !this.accelerationCanceled; if (this.isAcceleration) { if (initialState) { this.accelerationFlowCompleted = true; 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 @@
TXIDRequested
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 e8762fbec..2bbfd5e34 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -10,7 +10,6 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip import { StateService } from '@app/services/state.service'; import { PriceService } from '@app/services/price.service'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; -import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe'; const periodSeconds = { '1d': (60 * 60 * 24), @@ -45,14 +44,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() right: number | string = 10; @Input() left: number | string = 70; @Input() widget: boolean = false; + @Input() defaultFiat: boolean = false; + @Input() showLegend: boolean = true; + @Input() showYAxis: boolean = true; + adjustedLeft: number; + adjustedRight: number; data: any[] = []; fiatData: any[] = []; hoverData: any[] = []; conversions: any; allowZoom: boolean = false; - initialRight = this.right; - initialLeft = this.left; + selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false }; subscription: Subscription; @@ -77,7 +80,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { private relativeUrlPipe: RelativeUrlPipe, private priceService: PriceService, private fiatCurrencyPipe: FiatCurrencyPipe, - private fiatShortenerPipe: FiatShortenerPipe, private zone: NgZone, ) {} @@ -86,6 +88,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { if (!this.addressSummary$ && (!this.address || !this.stats)) { return; } + if (changes.defaultFiat) { + this.selected['Fiat'] = !!this.defaultFiat; + } if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { if (this.subscription) { this.subscription.unsubscribe(); @@ -118,7 +123,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } else if (this.conversions && this.conversions['USD']) { price = this.conversions['USD']; } - return { ...item, price: price } + return { ...item, price: price }; }); } }), @@ -147,7 +152,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { if (!summary) { return; } - + const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0); let runningTotal = total; const processData = summary.map(d => { @@ -161,7 +166,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { d }; }).reverse(); - + this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]); this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]); @@ -179,6 +184,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); + this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right; + this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40; + this.chartOptions = { color: [ new echarts.graphic.LinearGradient(0, 0, 0, 1, [ @@ -194,10 +202,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { grid: { top: 20, bottom: this.allowZoom ? 65 : 20, - right: this.right, - left: this.left, + right: this.adjustedRight, + left: this.adjustedLeft, }, - legend: !this.stateService.isAnyTestnet() ? { + legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? { data: [ { name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, @@ -245,21 +253,22 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { let tooltip = '
'; const hasTx = data[0].data[2].txid; + const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + tooltip += `
+
+
${date}
`; + if (hasTx) { const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; - tooltip += `${header}`; + tooltip += `
${header}
`; } - - const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); - - tooltip += `
-
`; - + const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal); const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD'); - + const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0); const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0); const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)'); @@ -291,7 +300,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } } - tooltip += `
${date}
`; + tooltip += `
`; return tooltip; }.bind(this) }, @@ -307,22 +316,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { type: 'value', position: 'left', axisLabel: { + show: this.showYAxis, color: 'rgb(110, 112, 121)', formatter: (val): string => { let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); if (valSpan > 100_000_000_000) { - return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`; } else if (valSpan > 1_000_000_000) { - return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`; } else if (valSpan > 100_000_000) { return `${(val / 100_000_000).toFixed(1)} BTC`; } else if (valSpan > 10_000_000) { return `${(val / 100_000_000).toFixed(2)} BTC`; } else if (valSpan > 1_000_000) { + if (maxValue > 100_000_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 3, undefined, true)} BTC`; + } return `${(val / 100_000_000).toFixed(3)} BTC`; } else { - return `${this.amountShortenerPipe.transform(val, 0)} sats`; + return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`; } } }, @@ -334,9 +347,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { { type: 'value', axisLabel: { + show: this.showYAxis, color: 'rgb(110, 112, 121)', formatter: function(val) { - return this.fiatShortenerPipe.transform(val, null, 'USD'); + return `$${this.amountShortenerPipe.transform(val, 3, undefined, true, true)}`; }.bind(this) }, splitLine: { @@ -390,8 +404,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { type: 'slider', brushSelect: false, realtime: true, - left: this.left, - right: this.right, + left: this.adjustedLeft, + right: this.adjustedRight, selectedDataBackground: { lineStyle: { color: '#fff', @@ -404,7 +418,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { onChartClick(e) { if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) { - this.zone.run(() => { + this.zone.run(() => { const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`); if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { window.open(url); @@ -421,26 +435,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { onLegendSelectChanged(e) { this.selected = e.selected; - this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight; - this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40; + this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right; + this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40; this.chartOptions = { grid: { - right: this.right, - left: this.left, + right: this.adjustedRight, + left: this.adjustedLeft, }, legend: { selected: this.selected, }, dataZoom: this.allowZoom ? [{ - left: this.left, - right: this.right, + left: this.adjustedLeft, + right: this.adjustedRight, }, { - left: this.left, - right: this.right, + left: this.adjustedLeft, + right: this.adjustedRight, }] : undefined }; - + if (this.chartInstance) { this.chartInstance.setOption(this.chartOptions); } @@ -464,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/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' }} + - - - + {{ copiedMessage }} diff --git a/frontend/src/app/components/clipboard/clipboard.component.scss b/frontend/src/app/components/clipboard/clipboard.component.scss index 49294e548..6ae620ae7 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.scss +++ b/frontend/src/app/components/clipboard/clipboard.component.scss @@ -7,7 +7,19 @@ padding-left: 0.4rem; } -img { - position: relative; - left: -3px; -} \ No newline at end of file +.copied-message { + background: color-mix(in srgb, var(--active-bg) 95%, transparent); + color: var(--fg); + font-family: sans-serif; + font-size: .8rem; + font-weight: 400; + text-decoration: none; + text-align: left; + padding: .6em .75rem; + border-radius: 4px; + position: absolute; + white-space: nowrap; + box-shadow: 0 .5rem 1rem -.5rem #000; + z-index: 1000; + opacity: .9; +} diff --git a/frontend/src/app/components/clipboard/clipboard.component.ts b/frontend/src/app/components/clipboard/clipboard.component.ts index 6e577d8b3..31f882d12 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.ts +++ b/frontend/src/app/components/clipboard/clipboard.component.ts @@ -1,6 +1,4 @@ -import { Component, ViewChild, ElementRef, AfterViewInit, Input, ChangeDetectionStrategy } from '@angular/core'; -import * as ClipboardJS from 'clipboard'; -import * as tlite from 'tlite'; +import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; @Component({ selector: 'app-clipboard', @@ -8,15 +6,14 @@ import * as tlite from 'tlite'; styleUrls: ['./clipboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ClipboardComponent implements AfterViewInit { - @ViewChild('btn') btn: ElementRef; - @ViewChild('buttonWrapper') buttonWrapper: ElementRef; +export class ClipboardComponent { @Input() button = false; @Input() class = 'btn btn-secondary ml-1'; @Input() size: 'small' | 'normal' | 'large' = 'normal'; @Input() text: string; @Input() leftPadding = true; copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`; + showMessage = false; widths = { small: '10', @@ -24,22 +21,40 @@ export class ClipboardComponent implements AfterViewInit { large: '18', }; - clipboard: any; + constructor( + private cd: ChangeDetectorRef, + ) { } - constructor() { } - - ngAfterViewInit() { - this.clipboard = new ClipboardJS(this.btn.nativeElement); - this.clipboard.on('success', () => { - tlite.show(this.buttonWrapper.nativeElement); - setTimeout(() => { - tlite.hide(this.buttonWrapper.nativeElement); - }, 1000); - }); + async copyText() { + if (this.text && !this.showMessage) { + try { + await this.copyToClipboard(this.text); + this.showMessage = true; + this.cd.markForCheck(); + setTimeout(() => { + this.showMessage = false; + this.cd.markForCheck(); + }, 1000); + } catch (error) { + console.error('Clipboard copy failed:', error); + } + } } - onDestroy() { - this.clipboard.destroy(); + async copyToClipboard(text: string) { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + } else { + // Use the 'out of viewport hidden text area' trick on non-secure contexts + const textarea = document.createElement('textarea'); + textarea.value = this.text; + textarea.style.opacity = '0'; + textarea.setAttribute('readonly', 'true'); // Don't trigger keyboard on mobile + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } } } diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html index 13f49c5df..8ca1a5ac4 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -238,7 +238,7 @@   - + @@ -281,9 +281,11 @@
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/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..faa0003c4 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,119 @@
+ + +

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 +   + + +
+
+
+
+
+
+ +

Blocks

@@ -194,7 +306,7 @@ {{ block.height }} + + + @@ -28,6 +31,15 @@ + + +
- ‎{{ 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..31d12474f 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,55 @@ div.scrollable { .taller-row { height: 75px; +} + +.stratum-table { + width: 100%; + + .merkle { + width: 100px; + } + + .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; + } +} + +.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..23b795613 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) { 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/server-health/server-health.component.html b/frontend/src/app/components/server-health/server-health.component.html index 6a0a905f9..a3a4a31e5 100644 --- a/frontend/src/app/components/server-health/server-health.component.html +++ b/frontend/src/app/components/server-health/server-health.component.html @@ -19,6 +19,9 @@ RTT RTT HeightFrontBackElectrs
{{ i + 1 }}{{ (host.rtt / 1000) | number : '1.1-1' }} {{ host.rtt == null ? '' : 's'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }} {{ host.rtt | number : '1.0-0' }} {{ host.rtt == null ? '' : 'ms'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }} {{ host.latestHeight }} {{ !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅')) }} + @if (host.hashes?.[type]) { + {{ host.hashes[type].slice(0, 8) || '?' }} + } @else { + ? + } +
diff --git a/frontend/src/app/components/server-health/server-health.component.scss b/frontend/src/app/components/server-health/server-health.component.scss index ff4ec1384..4aa58732b 100644 --- a/frontend/src/app/components/server-health/server-health.component.scss +++ b/frontend/src/app/components/server-health/server-health.component.scss @@ -9,7 +9,7 @@ } .status-panel { - max-width: 720px; + max-width: 1000px; margin: auto; padding: 1em; background: var(--box-bg); 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..08d7fb0ef --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.html @@ -0,0 +1,49 @@ +
+

Stratum Jobs

+ +
+ +
+ + + + + + + + + + + + @for (row of rows; track row.job.pool) { + + + + + @for (cell of row.merkleCells; track $index) { + + } + + + } + +
HeightRewardCoinbase Tag + Merkle Branches + Pool
+ {{ row.job.height }} + + + + {{ row.job.tag }} + +
+
+ @if (pools[row.job.pool]) { + + + {{ pools[row.job.pool].name}} + + } +
+
+
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..6679f2257 --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.scss @@ -0,0 +1,108 @@ +.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; + } + } + } +} + +.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..6f252babe --- /dev/null +++ b/frontend/src/app/components/stratum/stratum-list/stratum-list.component.ts @@ -0,0 +1,224 @@ +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): string[] { + let lastHash = ''; + const ids: string[] = []; + for (let i = 0; i < numBranches; i++) { + if (merkleBranches[i]) { + lastHash = merkleBranches[i]; + } + ids.push(`${i}-${lastHash}`); + } + 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) }; + } + 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]; + } + + 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..797694919 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' }} +
()
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 7a355f38d..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' }} -
- () -
+
Fee {{ tx.fee | number }} sats - @if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { + @if (isAcceleration && accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} sats } - +
Accelerated fee rate
- +
+
@@ -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/wallet/wallet-preview.component.html b/frontend/src/app/components/wallet/wallet-preview.component.html new file mode 100644 index 000000000..b2ce37614 --- /dev/null +++ b/frontend/src/app/components/wallet/wallet-preview.component.html @@ -0,0 +1,31 @@ +
+ + Wallet + +
+
+ + + + + + + + + + + + + + + + + +
Addresses{{ addressStrings.length }}UTXOs{{ walletStats.utxos }}
Balance (BTC)Balance (USD)
+
+
+
+ +
+
+
diff --git a/frontend/src/app/components/wallet/wallet-preview.component.scss b/frontend/src/app/components/wallet/wallet-preview.component.scss new file mode 100644 index 000000000..62037b901 --- /dev/null +++ b/frontend/src/app/components/wallet/wallet-preview.component.scss @@ -0,0 +1,31 @@ +.title-wrapper { + padding: 0 15px; +} + +.graph-col { + height: 350px; + text-align: center; + padding: 0; + margin-left: 2px; + margin-right: 15px; +} + +.table-col { + overflow: hidden; +} + +.table { + font-size: 32px; + + ::ng-deep .symbol { + font-size: 24px; + } + + .spacer { + background: none; + } +} + +.fiat { + display: block; +} diff --git a/frontend/src/app/components/wallet/wallet-preview.component.ts b/frontend/src/app/components/wallet/wallet-preview.component.ts new file mode 100644 index 000000000..0387822aa --- /dev/null +++ b/frontend/src/app/components/wallet/wallet-preview.component.ts @@ -0,0 +1,245 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators'; +import { Address, AddressTxSummary, ChainStats, Transaction } from '@interfaces/electrs.interface'; +import { StateService } from '@app/services/state.service'; +import { ApiService } from '@app/services/api.service'; +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 { OpenGraphService } from '../../services/opengraph.service'; +import { WebsocketService } from '../../services/websocket.service'; + +class WalletStats implements ChainStats { + addresses: string[]; + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + + constructor (stats: ChainStats[], addresses: string[]) { + Object.assign(this, stats.reduce((acc, stat) => { + acc.funded_txo_count += stat.funded_txo_count; + acc.funded_txo_sum += stat.funded_txo_sum; + acc.spent_txo_count += stat.spent_txo_count; + acc.spent_txo_sum += stat.spent_txo_sum; + return acc; + }, { + funded_txo_count: 0, + funded_txo_sum: 0, + spent_txo_count: 0, + spent_txo_sum: 0, + tx_count: 0, + }) + ); + this.addresses = addresses; + } + + public addTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { + this.spendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (this.addresses.includes(vout.scriptpubkey_address)) { + this.fundTxo(vout.value); + } + } + this.tx_count++; + } + + public removeTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { + this.unspendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (this.addresses.includes(vout.scriptpubkey_address)) { + this.unfundTxo(vout.value); + } + } + this.tx_count--; + } + + private fundTxo(value: number): void { + this.funded_txo_sum += value; + this.funded_txo_count++; + } + + private unfundTxo(value: number): void { + this.funded_txo_sum -= value; + this.funded_txo_count--; + } + + private spendTxo(value: number): void { + this.spent_txo_sum += value; + this.spent_txo_count++; + } + + private unspendTxo(value: number): void { + this.spent_txo_sum -= value; + this.spent_txo_count--; + } + + get balance(): number { + return this.funded_txo_sum - this.spent_txo_sum; + } + + get totalReceived(): number { + return this.funded_txo_sum; + } + + get utxos(): number { + return this.funded_txo_count - this.spent_txo_count; + } +} + +@Component({ + selector: 'app-wallet-preview', + templateUrl: './wallet-preview.component.html', + styleUrls: ['./wallet-preview.component.scss'] +}) +export class WalletPreviewComponent implements OnInit, OnDestroy { + network = ''; + + addresses: Address[] = []; + addressStrings: string[] = []; + walletName: string; + isLoadingWallet = true; + wallet$: Observable>; + walletAddresses$: Observable>; + walletSummary$: Observable; + walletStats$: Observable; + error: any; + walletSubscription: Subscription; + + collapseAddresses: boolean = true; + + fullyLoaded = false; + txCount = 0; + received = 0; + sent = 0; + chainBalance = 0; + + constructor( + private route: ActivatedRoute, + private stateService: StateService, + private apiService: ApiService, + private seoService: SeoService, + private websocketService: WebsocketService, + private openGraphService: OpenGraphService, + ) { } + + ngOnInit(): void { + this.websocketService.want(['blocks', 'stats']); + this.stateService.networkChanged$.subscribe((network) => this.network = network); + this.wallet$ = this.route.paramMap.pipe( + map((params: ParamMap) => params.get('wallet') as string), + tap((walletName: string) => { + this.walletName = walletName; + this.openGraphService.waitFor('wallet-addresses-' + this.walletName); + this.openGraphService.waitFor('wallet-data-' + this.walletName); + this.openGraphService.waitFor('wallet-txs-' + this.walletName); + this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`); + }), + switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe( + catchError((err) => { + this.error = err; + this.seoService.logSoft404(); + console.log(err); + this.openGraphService.fail('wallet-addresses-' + this.walletName); + this.openGraphService.fail('wallet-data-' + this.walletName); + this.openGraphService.fail('wallet-txs-' + this.walletName); + return of({}); + }) + )), + shareReplay(1), + ); + + this.walletAddresses$ = this.wallet$.pipe( + map(wallet => { + const walletInfo: Record = {}; + for (const address of Object.keys(wallet)) { + walletInfo[address] = { + address, + chain_stats: wallet[address].stats, + mempool_stats: { + funded_txo_count: 0, + funded_txo_sum: 0, + spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 + }, + }; + } + return walletInfo; + }), + tap(() => { + this.isLoadingWallet = false; + }) + ); + + this.walletSubscription = this.walletAddresses$.subscribe(wallet => { + this.addressStrings = Object.keys(wallet); + this.addresses = Object.values(wallet); + this.openGraphService.waitOver('wallet-addresses-' + this.walletName); + }); + + this.walletSummary$ = this.wallet$.pipe( + map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))), + tap(() => { + this.openGraphService.waitOver('wallet-txs-' + this.walletName); + }) + ); + + this.walletStats$ = this.wallet$.pipe( + switchMap(wallet => { + const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet)); + return this.stateService.walletTransactions$.pipe( + startWith([]), + scan((stats, newTransactions) => { + for (const tx of newTransactions) { + stats.addTx(tx); + } + return stats; + }, walletStats), + ); + }), + tap(() => { + this.openGraphService.waitOver('wallet-data-' + this.walletName); + }) + ); + } + + deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { + const transactions = new Map(); + for (const tx of walletTransactions) { + if (transactions.has(tx.txid)) { + transactions.get(tx.txid).value += tx.value; + } else { + transactions.set(tx.txid, tx); + } + } + return Array.from(transactions.values()).sort((a, b) => { + if (a.height === b.height) { + return b.tx_position - a.tx_position; + } + return b.height - a.height; + }); + } + + normalizeAddress(address: string): string { + if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) { + return address.toLowerCase(); + } else { + return address; + } + } + + ngOnDestroy(): void { + this.walletSubscription.unsubscribe(); + } +} 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 cad4b47bf..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 = [ { @@ -9339,7 +11685,7 @@ export const restApiDocsData = [ fragment: "accelerator-history", title: "GET Acceleration History", description: { - default: "

Returns the user's past acceleration requests.

Pass one of the following for :status: all, requested, accelerating, mined, completed, failed. Pass true in :details to get a detailed history of the acceleration request.

" + default: "

Returns the user's past acceleration requests.

Pass one of the following for :status (required): all, requested, accelerating, mined, completed, failed.
Pass true in :details to get a detailed history of the acceleration request.

" }, urlString: "/v1/services/accelerator/history?status=:status&details=:details", showConditions: [""], @@ -9449,6 +11795,36 @@ export const restApiDocsData = [ } } }, + { + options: { officialOnly: true }, + type: "endpoint", + category: "accelerator-private", + httpRequestMethod: "POST", + fragment: "accelerator-cancel", + title: "POST Cancel Acceleration (Pro)", + description: { + default: "

Sends a request to cancel an acceleration in the accelerating status.
You can retreive eligible acceleration id using the history endpoint GET /api/v1/services/accelerator/history?status=accelerating." + }, + urlString: "/v1/services/accelerator/cancel", + showConditions: [""], + showJsExamples: showJsExamplesDefaultFalse, + codeExample: { + default: { + codeTemplate: { + curl: `%{1}" "[[hostname]][[baseNetworkUrl]]/api/v1/services/accelerator/cancel`, //custom interpolation technique handled in replaceCurlPlaceholder() + commonJS: ``, + esModule: `` + }, + codeSampleMainnet: { + esModule: [], + commonJS: [], + curl: ["id=42"], + headers: "X-Mempool-Auth: stacksats", + response: `HTTP/1.1 200 OK`, + }, + } + } + }, ]; export const faqData = [ 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/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 4e6b00637..f882b4221 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/h import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; import { AddressComponent } from '@components/address/address.component'; import { WalletComponent } from '@components/wallet/wallet.component'; +import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component'; import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component'; @@ -49,6 +50,7 @@ import { CommonModule } from '@angular/common'; MempoolBlockComponent, AddressComponent, WalletComponent, + WalletPreviewComponent, MiningDashboardComponent, AcceleratorDashboardComponent, 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..4d85a938d 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; diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 89c8e3884..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; } @@ -144,4 +147,30 @@ export interface HealthCheckHost { link?: string; statusPage?: SafeResourceUrl; flag?: string; + hashes?: { + frontend?: string; + backend?: string; + 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/previews.routing.module.ts b/frontend/src/app/previews.routing.module.ts index 92ea113b8..790a8eee8 100644 --- a/frontend/src/app/previews.routing.module.ts +++ b/frontend/src/app/previews.routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component'; import { BlockPreviewComponent } from '@components/block/block-preview.component'; import { AddressPreviewComponent } from '@components/address/address-preview.component'; +import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; import { PoolPreviewComponent } from '@components/pool/pool-preview.component'; import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component'; @@ -20,6 +21,11 @@ const routes: Routes = [ children: [], component: AddressPreviewComponent }, + { + path: 'wallet/:wallet', + children: [], + component: WalletPreviewComponent + }, { path: 'tx/:id', children: [], 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 2b0f884ff..5e882cd02 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; @@ -131,20 +130,20 @@ export class ServicesApiServices { return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/estimate`, { txInput: txInput }, { observe: 'response' }); } - accelerate$(txInput: string, userBid: number, accelerationUUID: string) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid, accelerationUUID: accelerationUUID }); + accelerate$(txInput: string, userBid: number) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid}); } - accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD }); + accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, userApprovedUSD: number) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); } - accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD }); + accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, userApprovedUSD: number) { + 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, accelerationUUID: string, userApprovedUSD: number) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, 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 }); } 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 5ec13c03f..b82b32dd5 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -36,7 +36,9 @@ 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; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -142,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); } @@ -226,10 +231,11 @@ export class WebsocketService { clearTimeout(this.stoppingTrackMempoolBlock); } // skip duplicate tracking requests - if (force || this.trackingMempoolBlock !== block) { + if (force || this.trackingMempoolBlock !== block || this.network !== this.trackingMempoolBlockNetwork) { this.websocketSubject.next({ 'track-mempool-block': block }); this.isTrackingMempoolBlock = true; this.trackingMempoolBlock = block; + this.trackingMempoolBlockNetwork = this.network; return true; } return false; @@ -287,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 }); } @@ -510,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/common.utils.ts b/frontend/src/app/shared/common.utils.ts index f329b55e4..9b53600c1 100644 --- a/frontend/src/app/shared/common.utils.ts +++ b/frontend/src/app/shared/common.utils.ts @@ -214,19 +214,6 @@ export function renderSats(value: number, network: string, mode: 'sats' | 'btc' } } -export function insecureRandomUUID(): string { - const hexDigits = '0123456789abcdef'; - const uuidLengths = [8, 4, 4, 4, 12]; - let uuid = ''; - for (const length of uuidLengths) { - for (let i = 0; i < length; i++) { - uuid += hexDigits[Math.floor(Math.random() * 16)]; - } - uuid += '-'; - } - return uuid.slice(0, -1); -} - export function sleep$(ms: number): Promise { return new Promise((resolve) => { setTimeout(() => { 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 d82bb8062..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 @@ -5,7 +5,7 @@
- +
diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.scss b/frontend/src/app/shared/components/global-footer/global-footer.component.scss index bf47d5489..5f8c9f566 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.scss +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.scss @@ -303,6 +303,10 @@ footer .nowrap { margin: 0 auto; } + .enterprise-logo { + max-width: 100%; + } + footer .site-options { float: none; margin-top: 15px; 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/pipes/amount-shortener.pipe.ts b/frontend/src/app/shared/pipes/amount-shortener.pipe.ts index 71ff76f77..ec50285cb 100644 --- a/frontend/src/app/shared/pipes/amount-shortener.pipe.ts +++ b/frontend/src/app/shared/pipes/amount-shortener.pipe.ts @@ -8,8 +8,12 @@ export class AmountShortenerPipe implements PipeTransform { const digits = args[0] ?? 1; const unit = args[1] || undefined; const isMoney = args[2] || false; + const sigfigs = args[3] || false; // if true, "digits" is the number of significant digits, not the number of decimal places if (num < 1000) { + if (sigfigs) { + return Number(num.toPrecision(digits)); + } return num.toFixed(digits); } @@ -25,10 +29,15 @@ export class AmountShortenerPipe implements PipeTransform { const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; const item = lookup.slice().reverse().find((item) => num >= item.value); - if (unit !== undefined) { - return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + ' ' + item.symbol + unit : '0'; - } else { - return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0'; + if (!item) { + return '0'; } + + const scaledNum = num / item.value; + const formattedNum = Number(sigfigs ? scaledNum.toPrecision(digits) : scaledNum.toFixed(digits)).toString(); + + return unit !== undefined + ? formattedNum + ' ' + item.symbol + unit + : formattedNum + item.symbol; } } \ No newline at end of file diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index a855f11b5..283f9eb54 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 } 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'; @@ -134,6 +139,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ ThemeSelectorComponent, RateUnitSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, NoSanitizePipe, @@ -196,6 +202,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ DifficultyAdjustmentsTable, BlocksList, RbfList, + StratumList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -283,6 +290,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ RateUnitSelectorComponent, ThemeSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, Hex2asciiPipe, @@ -339,6 +347,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ AmountShortenerPipe, DifficultyAdjustmentsTable, BlocksList, + StratumList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -448,5 +457,8 @@ export class SharedModule { library.addIcons(faTimeline); library.addIcons(faCircleXmark); library.addIcons(faCalendarCheck); + library.addIcons(faMoneyBillTrendUp); + library.addIcons(faRobot); + library.addIcons(faShareNodes); } } diff --git a/frontend/src/index.mempool.meta.html b/frontend/src/index.mempool.meta.html new file mode 100644 index 000000000..92154f8db --- /dev/null +++ b/frontend/src/index.mempool.meta.html @@ -0,0 +1,45 @@ + + + + + + Metaplanet Inc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/resources/meta/favicons/android-chrome-192x192.png b/frontend/src/resources/meta/favicons/android-chrome-192x192.png new file mode 100644 index 000000000..448d5ce91 Binary files /dev/null and b/frontend/src/resources/meta/favicons/android-chrome-192x192.png differ diff --git a/frontend/src/resources/meta/favicons/android-chrome-512x512.png b/frontend/src/resources/meta/favicons/android-chrome-512x512.png new file mode 100644 index 000000000..5164c7746 Binary files /dev/null and b/frontend/src/resources/meta/favicons/android-chrome-512x512.png differ diff --git a/frontend/src/resources/meta/favicons/apple-touch-icon.png b/frontend/src/resources/meta/favicons/apple-touch-icon.png new file mode 100644 index 000000000..bd5b04f4b Binary files /dev/null and b/frontend/src/resources/meta/favicons/apple-touch-icon.png differ diff --git a/frontend/src/resources/meta/favicons/favicon-16x16.png b/frontend/src/resources/meta/favicons/favicon-16x16.png new file mode 100644 index 000000000..2c9716325 Binary files /dev/null and b/frontend/src/resources/meta/favicons/favicon-16x16.png differ diff --git a/frontend/src/resources/meta/favicons/favicon-32x32.png b/frontend/src/resources/meta/favicons/favicon-32x32.png new file mode 100644 index 000000000..6fb88e678 Binary files /dev/null and b/frontend/src/resources/meta/favicons/favicon-32x32.png differ diff --git a/frontend/src/resources/meta/favicons/favicon.ico b/frontend/src/resources/meta/favicons/favicon.ico new file mode 100644 index 000000000..bd25b818f Binary files /dev/null and b/frontend/src/resources/meta/favicons/favicon.ico differ diff --git a/frontend/src/resources/meta/favicons/site.webmanifest b/frontend/src/resources/meta/favicons/site.webmanifest new file mode 100644 index 000000000..45dc8a206 --- /dev/null +++ b/frontend/src/resources/meta/favicons/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/frontend/src/resources/meta/meta-preview.png b/frontend/src/resources/meta/meta-preview.png new file mode 100644 index 000000000..d569aae0e Binary files /dev/null and b/frontend/src/resources/meta/meta-preview.png differ diff --git a/frontend/src/resources/metalogo.svg b/frontend/src/resources/metalogo.svg new file mode 100644 index 000000000..e3174dc62 --- /dev/null +++ b/frontend/src/resources/metalogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/production/bitcoin.conf b/production/bitcoin.conf index 63baa32b5..57d993eb4 100644 --- a/production/bitcoin.conf +++ b/production/bitcoin.conf @@ -4,7 +4,6 @@ txindex=1 coinstatsindex=1 listen=1 discover=1 -par=16 dbcache=8192 mempoolfullrbf=1 maxconnections=100 diff --git a/production/mempool-build-all b/production/mempool-build-all index 84ea1b5ec..377deb316 100755 --- a/production/mempool-build-all +++ b/production/mempool-build-all @@ -131,8 +131,8 @@ export NVM_DIR="${HOME}/.nvm" source "${NVM_DIR}/nvm.sh" # what to look for -frontends=(mainnet liquid onbtc) -backends=(mainnet testnet testnet4 signet liquid liquidtestnet onbtc) +frontends=(mainnet liquid onbtc bitb meta) +backends=(mainnet testnet testnet4 signet liquid liquidtestnet onbtc bitb) frontend_repos=() backend_repos=() @@ -148,7 +148,7 @@ for repo in $backends;do done # update all repos -for repo in $backend_repos;do +for repo in $frontend_repos $backend_repos;do update_repo "${repo}" done diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index f57978043..39d82d8d1 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -153,6 +153,6 @@ }, "WALLETS": { "ENABLED": true, - "WALLETS": ["BITB"] + "WALLETS": ["BITB", "3350"] } } diff --git a/production/mempool-frontend-config.meta.json b/production/mempool-frontend-config.meta.json new file mode 100644 index 000000000..dad27de53 --- /dev/null +++ b/production/mempool-frontend-config.meta.json @@ -0,0 +1,19 @@ +{ + "OFFICIAL_MEMPOOL_SPACE": true, + "TESTNET_ENABLED": true, + "TESTNET4_ENABLED": true, + "LIQUID_ENABLED": true, + "LIQUID_TESTNET_ENABLED": true, + "BISQ_ENABLED": true, + "BISQ_SEPARATE_BACKEND": true, + "SIGNET_ENABLED": true, + "MEMPOOL_WEBSITE_URL": "https://mempool.space", + "LIQUID_WEBSITE_URL": "https://liquid.network", + "BISQ_WEBSITE_URL": "https://bisq.markets", + "ITEMS_PER_PAGE": 25, + "LIGHTNING": true, + "ACCELERATOR": true, + "PUBLIC_ACCELERATIONS": true, + "AUDIT": true, + "CUSTOMIZATION": "custom-meta-config.json" +} diff --git a/production/mempool-start-all b/production/mempool-start-all index c08f1ec07..9d4c6ee58 100755 --- a/production/mempool-start-all +++ b/production/mempool-start-all @@ -5,6 +5,7 @@ nvm use v20.12.0 # start all mempool backends that exist for site in mainnet mainnet-lightning testnet testnet-lightning testnet4 signet signet-lightning liquid liquidtestnet;do + [ ! -e "${HOME}/${site}/backend/" ] && continue cd "${HOME}/${site}/backend/" && \ echo "starting mempool backend: ${site}" && \ screen -dmS "${site}" sh -c 'while true;do npm run start-production;sleep 1;done' @@ -15,7 +16,7 @@ screen -dmS x startx sleep 3 # start unfurlers for each frontend -for site in mainnet liquid onbtc;do +for site in mainnet liquid onbtc bitb meta;do cd "$HOME/${site}/unfurler" && \ echo "starting mempool unfurler: ${site}" && \ screen -dmS "unfurler-${site}" sh -c 'while true;do npm run unfurler;sleep 2;done' 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/production/unfurler-config.bitb.json b/production/unfurler-config.bitb.json new file mode 100644 index 000000000..8a4f14448 --- /dev/null +++ b/production/unfurler-config.bitb.json @@ -0,0 +1,17 @@ +{ + "SERVER": { + "HOST": "https://bitb.tk7.mempool.space", + "HTTP_PORT": 8006 + }, + "MEMPOOL": { + "HTTP_HOST": "http://127.0.0.1", + "HTTP_PORT": 86, + "NETWORK": "bitb" + }, + "PUPPETEER": { + "CLUSTER_SIZE": 8, + "EXEC_PATH": "/usr/local/bin/chrome", + "MAX_PAGE_AGE": 86400, + "RENDER_TIMEOUT": 3000 + } +} diff --git a/production/unfurler-config.meta.json b/production/unfurler-config.meta.json new file mode 100644 index 000000000..0fe1f1780 --- /dev/null +++ b/production/unfurler-config.meta.json @@ -0,0 +1,17 @@ +{ + "SERVER": { + "HOST": "https://metaplanet.mempool.space", + "HTTP_PORT": 8005 + }, + "MEMPOOL": { + "HTTP_HOST": "http://127.0.0.1", + "HTTP_PORT": 85, + "NETWORK": "meta" + }, + "PUPPETEER": { + "CLUSTER_SIZE": 8, + "EXEC_PATH": "/usr/local/bin/chrome", + "MAX_PAGE_AGE": 86400, + "RENDER_TIMEOUT": 3000 + } +} 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 8d6f6fe1d..dcea29cde 100644 --- a/unfurler/src/routes.ts +++ b/unfurler/src/routes.ts @@ -85,6 +85,13 @@ const routes = { return `Address: ${path[0]}`; } }, + wallet: { + render: true, + params: 1, + getTitle(path) { + return `Wallet: ${path[0]}`; + } + }, blocks: { title: "Blocks", fallbackImg: '/resources/previews/blocks.jpg', @@ -263,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', @@ -281,6 +289,50 @@ export const networks = { routes: routes.lightning.routes, } } + }, + 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', + routes: { // only dynamic routes supported + block: routes.block, + address: routes.address, + wallet: routes.wallet, + tx: routes.tx, + mining: { + title: "Mining", + routes: { + pool: routes.mining.routes.pool, + } + }, + lightning: { + title: "Lightning", + routes: routes.lightning.routes, + } + } + }, + meta: { + networkName: 'Metaplanet', + title: 'Metaplanet Inc.', + description: 'Secure the Future with Bitcoin', + fallbackImg: '/resources/meta/meta-preview.png', + routes: { // only dynamic routes supported + block: routes.block, + address: routes.address, + wallet: routes.wallet, + tx: routes.tx, + mining: { + title: "Mining", + routes: { + pool: routes.mining.routes.pool, + } + }, + lightning: { + title: "Lightning", + routes: routes.lightning.routes, + } + } } };