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 eb5cca6f8..b56ca4861 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -23,6 +23,11 @@ import { calculateMempoolTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; import poolsUpdater from '../../tasks/pools-updater'; +const TXID_REGEX = /^[a-f0-9]{64}$/i; +const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i; +const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i; +const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i; + class BitcoinRoutes { public initRoutes(app: Application) { app @@ -95,7 +100,7 @@ class BitcoinRoutes { res.set('Content-Type', 'application/json'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get init data'); } } @@ -114,7 +119,7 @@ class BitcoinRoutes { const result = mempoolBlocks.getMempoolBlocks(); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get mempool blocks'); } } @@ -126,7 +131,10 @@ class BitcoinRoutes { const txIds: string[] = []; for (const _txId in req.query.txId) { if (typeof req.query.txId[_txId] === 'string') { - txIds.push(req.query.txId[_txId].toString()); + const txid = req.query.txId[_txId].toString(); + if (TXID_REGEX.test(txid)) { + txIds.push(txid); + } } } @@ -145,18 +153,22 @@ class BitcoinRoutes { handleError(req, res, 400, 'Too many txids requested'); return; } + if (txids.some((txid) => !TXID_REGEX.test(txid))) { + handleError(req, res, 400, 'Invalid txids format'); + return; + } try { const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids); res.json(batchedOutspends); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get batched outspends'); } } private async $getCpfpInfo(req: Request, res: Response) { - if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { - handleError(req, res, 501, `Invalid transaction ID.`); + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); return; } @@ -189,7 +201,7 @@ class BitcoinRoutes { try { cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); } catch (e) { - handleError(req, res, 500, 'failed to get CPFP info'); + handleError(req, res, 500, 'Failed to get CPFP info'); return; } } @@ -210,6 +222,10 @@ class BitcoinRoutes { } private async getTransaction(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true); res.json(transaction); @@ -217,12 +233,18 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get transaction'); } } private async getRawTransaction(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(req.params.txId, true); res.setHeader('content-type', 'text/plain'); @@ -231,8 +253,10 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get raw transaction'); } } @@ -297,14 +321,18 @@ class BitcoinRoutes { } } catch (e: any) { if (e instanceof Error && new RegExp(notFoundError).test(e.message)) { - handleError(req, res, 404, e.message); + handleError(req, res, 404, notFoundError); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to process PSBT'); } } } private async getTransactionStatus(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true); res.json(transaction.status); @@ -312,36 +340,54 @@ class BitcoinRoutes { let statusCode = 500; if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { statusCode = 404; + handleError(req, res, statusCode, 'No such mempool or blockchain transaction'); + return; } - handleError(req, res, statusCode, e instanceof Error ? e.message : e); + handleError(req, res, statusCode, 'Failed to get transaction status'); } } private async getStrippedBlockTransactions(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transactions); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block summary'); } } private async getStrippedBlockTransaction(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } + if (!TXID_REGEX.test(req.params.txid)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid); if (!transaction) { - handleError(req, res, 404, `transaction not found in summary`); + handleError(req, res, 404, `Transaction not found in summary`); return; } res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transaction); } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction from summary'); } } private async getBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const block = await blocks.$getBlock(req.params.hash); @@ -353,53 +399,69 @@ class BitcoinRoutes { } else if (blockAge > 30 * day) { cacheDuration = 10 * day; } else { - cacheDuration = 600 + cacheDuration = 600; } res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.json(block); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block'); } } private async getBlockHeader(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const blockHeader = await bitcoinApi.$getBlockHeader(req.params.hash); res.setHeader('content-type', 'text/plain'); res.send(blockHeader); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block header'); } } private async getBlockAuditSummary(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash); if (auditSummary) { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(auditSummary); } else { - handleError(req, res, 404, `audit not available`); + handleError(req, res, 404, `Audit not available`); return; } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit summary'); } } private async $getBlockTxAuditSummary(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } + if (!TXID_REGEX.test(req.params.txid)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const auditSummary = await blocks.$getBlockTxAuditSummary(req.params.hash, req.params.txid); if (auditSummary) { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(auditSummary); } else { - handleError(req, res, 404, `transaction audit not available`); + handleError(req, res, 404, `Transaction audit not available`); return; } } catch (e) { - res.status(500).send(e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction audit summary'); } } @@ -413,7 +475,7 @@ class BitcoinRoutes { return await this.getLegacyBlocks(req, res); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } @@ -455,7 +517,7 @@ class BitcoinRoutes { res.json(await blocks.$getBlocksBetweenHeight(from, to)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } @@ -490,11 +552,15 @@ class BitcoinRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(returnBlocks); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks'); } } private async getBlockTransactions(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0); @@ -515,7 +581,7 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100); - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block transactions'); } } @@ -524,7 +590,7 @@ class BitcoinRoutes { const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10)); res.send(blockHash); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block at height'); } } @@ -533,16 +599,20 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { const addressData = await bitcoinApi.$getAddress(req.params.address); res.json(addressData); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address'); } } @@ -551,6 +621,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { let lastTxId: string = ''; @@ -561,10 +635,10 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address transactions'); } } @@ -580,6 +654,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) { + handleError(req, res, 501, `Invalid scripthash`); + return; + } try { // electrum expects scripthashes in little-endian @@ -588,10 +666,10 @@ class BitcoinRoutes { res.json(addressData); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get script hash'); } } @@ -600,6 +678,10 @@ class BitcoinRoutes { handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); return; } + if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) { + handleError(req, res, 501, `Invalid scripthash`); + return; + } try { // electrum expects scripthashes in little-endian @@ -612,10 +694,10 @@ class BitcoinRoutes { res.json(transactions); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - handleError(req, res, 413, e instanceof Error ? e.message : e); + handleError(req, res, 413, e.message); return; } - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get script hash transactions'); } } @@ -628,10 +710,10 @@ class BitcoinRoutes { private async getAddressPrefix(req: Request, res: Response) { try { - const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); - res.send(blockHash); + const addressPrefix = await bitcoinApi.$getAddressPrefix(req.params.prefix); + res.send(addressPrefix); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get address prefix'); } } @@ -718,7 +800,7 @@ class BitcoinRoutes { res.setHeader('content-type', 'text/plain'); res.send(result.toString()); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get height at tip'); } } @@ -728,39 +810,55 @@ class BitcoinRoutes { res.setHeader('content-type', 'text/plain'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get hash at tip'); } } private async getRawBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const result = await bitcoinApi.$getRawBlock(req.params.hash); res.setHeader('content-type', 'application/octet-stream'); res.send(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get raw block'); } } private async getTxIdsForBlock(req: Request, res: Response) { + if (!BLOCK_HASH_REGEX.test(req.params.hash)) { + handleError(req, res, 501, `Invalid block hash`); + return; + } try { const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get txids for block'); } } private async validateAddress(req: Request, res: Response) { + if (!ADDRESS_REGEX.test(req.params.address)) { + handleError(req, res, 501, `Invalid address`); + return; + } try { const result = await bitcoinClient.validateAddress(req.params.address); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to validate address'); } } private async getRbfHistory(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const replacements = rbfCache.getRbfTree(req.params.txId) || null; const replaces = rbfCache.getReplaces(req.params.txId) || null; @@ -769,7 +867,7 @@ class BitcoinRoutes { replaces }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get rbf history'); } } @@ -778,7 +876,7 @@ class BitcoinRoutes { const result = rbfCache.getRbfTrees(false); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get rbf trees'); } } @@ -787,11 +885,15 @@ class BitcoinRoutes { const result = rbfCache.getRbfTrees(true); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get full rbf replacements'); } } private async getCachedTx(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const result = rbfCache.getTx(req.params.txId); if (result) { @@ -800,16 +902,20 @@ class BitcoinRoutes { res.status(204).send(); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get cached tx'); } } private async getTransactionOutspends(req: Request, res: Response) { + if (!TXID_REGEX.test(req.params.txId)) { + handleError(req, res, 501, `Invalid transaction ID`); + return; + } try { const result = await bitcoinApi.$getOutspends(req.params.txId); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get transaction outspends'); } } @@ -822,7 +928,7 @@ class BitcoinRoutes { handleError(req, res, 503, `Service Temporarily Unavailable`); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get difficulty change'); } } @@ -833,8 +939,8 @@ class BitcoinRoutes { const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); res.send(txIdResult); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to send raw transaction'); } } @@ -845,8 +951,8 @@ class BitcoinRoutes { const txIdResult = await bitcoinClient.sendRawTransaction(txHex); res.send(txIdResult); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to send raw transaction'); } } @@ -857,8 +963,8 @@ class BitcoinRoutes { const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); res.send(result); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to test transactions'); } } @@ -870,8 +976,8 @@ class BitcoinRoutes { const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined); res.send(result); } catch (e: any) { - handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) - : (e.message || 'Error')); + handleError(req, res, 400, (e.message && e.code) ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code }) + : 'Failed to submit package'); } } diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 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 47fd696e4..0ca58a785 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 = 92; + private static currentVersion = 95; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -776,12 +776,355 @@ class DatabaseMigration { await this.updateToSchemaVersion(91); } - // blocks pools-v2.json hash - if (databaseSchemaVersion < 92) { - await this.$executeQuery('ALTER TABLE `blocks` ADD definition_hash varchar(255) NOT NULL DEFAULT "5f32a67401929169f225f5db43c9efa795d1b159"'); - await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `definition_hash` (`definition_hash`)'); + // 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\`) + `); + } + + // blocks pools-v2.json hash + if (databaseSchemaVersion < 95) { + await this.$executeQuery('ALTER TABLE `blocks` ADD definition_hash varchar(255) NOT NULL DEFAULT "5f32a67401929169f225f5db43c9efa795d1b159"'); + await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `definition_hash` (`definition_hash`)'); + await this.updateToSchemaVersion(95); + } + + 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/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/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/index.ts b/backend/src/index.ts index d939b7423..c179b66bc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -324,7 +324,9 @@ class Server { 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/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/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/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.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 425e00d9e..4c935c57f 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'; @@ -94,7 +94,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { auth: IAuth | null = null; // accelerator stuff - accelerationUUID: string; accelerationSubscription: Subscription; difficultySubscription: Subscription; estimateSubscription: Subscription; @@ -138,7 +137,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 @@ -388,7 +386,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerationSubscription = this.servicesApiService.accelerate$( this.tx.txid, this.userBid, - this.accelerationUUID ).subscribe({ next: () => { this.processing = false; @@ -522,7 +519,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { tokenResult.token, cardTag, `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - this.accelerationUUID, costUSD ).subscribe({ next: () => { @@ -616,14 +612,22 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.processing = false; return; } + 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()); this.servicesApiService.accelerateWithGooglePay$( this.tx.txid, tokenResult.token, + verificationToken.token, cardTag, `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - this.accelerationUUID, - costUSD + costUSD, + verificationToken.userChallenged ).subscribe({ next: () => { this.processing = false; @@ -713,7 +717,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { tokenResult.token, tokenResult.details.cashAppPay.cashtag, tokenResult.details.cashAppPay.referenceId, - this.accelerationUUID, costUSD ).subscribe({ next: () => { @@ -749,6 +752,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 */ 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/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index e8762fbec..db9345b18 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); } @@ -469,7 +483,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { // Add a point at today's date to make the graph end at the current time extendedSummary.unshift({ time: Date.now() / 1000, value: 0 }); extendedSummary.reverse(); - + let oneHour = 60 * 60; // Fill gaps longer than interval for (let i = 0; i < extendedSummary.length - 1; i++) { @@ -482,7 +496,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { i += hours - 1; } } - + return extendedSummary.reverse(); } } 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..13cdd97ce 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 @@   - +
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..b3c6430a8 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -194,7 +194,7 @@ {{ block.height }} - ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + 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 Height + Front + Back + Electrs {{ i + 1 }} @@ -28,6 +31,15 @@ {{ (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/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 acadc8818..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' }} -
- () -
+ } @else { @@ -217,10 +214,10 @@ Fee {{ tx.fee | number }} sats - @if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { + @if (isAcceleration && accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} sats } - + } @else { @@ -247,7 +244,7 @@ @if (!isLoadingTx) { - @if ((cpfpInfo && hasEffectiveFeeRate) || accelerationInfo) { + @if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) { @if (isAcceleration) { Accelerated fee rate diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 4810e1d94..8c2d9de01 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -165,12 +165,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 @@
- +
@@ -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..dfe19ca74 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -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/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index cad4b47bf..1f83cabc9 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -9339,7 +9339,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 +9449,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/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/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 89c8e3884..d61610a2e 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -144,4 +144,9 @@ export interface HealthCheckHost { link?: string; statusPage?: SafeResourceUrl; flag?: string; + hashes?: { + frontend?: string; + backend?: string; + electrs?: string; + } } 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 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 @@ - ‎{{ 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/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/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/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..0d006b552 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -186,6 +186,7 @@ export class StateService { live2Chart$ = new Subject(); viewAmountMode$: BehaviorSubject<'btc' | 'sats' | 'fiat'>; + timezone$: BehaviorSubject; connectionState$ = new BehaviorSubject<0 | 1 | 2>(2); isTabHidden$: Observable; @@ -347,6 +348,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..0f5368244 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -37,6 +37,7 @@ export class WebsocketService { private isTrackingWallet: boolean = false; private trackingWalletName: string; private trackingMempoolBlock: number; + private trackingMempoolBlockNetwork: string; private stoppingTrackMempoolBlock: any | null = null; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -226,10 +227,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; 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/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..bfd4b84de 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,7 @@ 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 } 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 +36,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'; @@ -134,6 +135,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ ThemeSelectorComponent, RateUnitSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, NoSanitizePipe, @@ -283,6 +285,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/ RateUnitSelectorComponent, ThemeSelectorComponent, AmountSelectorComponent, + TimezoneSelectorComponent, ScriptpubkeyTypePipe, RelativeUrlPipe, Hex2asciiPipe, @@ -448,5 +451,6 @@ export class SharedModule { library.addIcons(faTimeline); library.addIcons(faCircleXmark); library.addIcons(faCalendarCheck); + library.addIcons(faMoneyBillTrendUp); } } 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/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, + } + } } };