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 91dcf35c5..f92b7cd0c 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -22,6 +22,11 @@ import rbfCache from '../rbf-cache'; import { calculateMempoolTxCpfp, calculateLocalTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; +const TXID_REGEX = /^[a-f0-9]{64}$/i; +const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i; +const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i; +const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i; + class BitcoinRoutes { public initRoutes(app: Application) { app @@ -92,7 +97,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'); } } @@ -111,7 +116,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'); } } @@ -123,7 +128,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); + } } } @@ -142,18 +150,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; } @@ -186,7 +198,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; } } @@ -207,6 +219,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); @@ -214,12 +230,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'); @@ -228,8 +250,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'); } } @@ -294,14 +318,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); @@ -309,36 +337,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); @@ -350,53 +396,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'); } } @@ -410,7 +472,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'); } } @@ -452,7 +514,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'); } } @@ -487,11 +549,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); @@ -512,7 +578,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'); } } @@ -521,7 +587,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'); } } @@ -530,16 +596,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'); } } @@ -548,6 +618,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 = ''; @@ -558,10 +632,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'); } } @@ -577,6 +651,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 @@ -585,10 +663,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'); } } @@ -597,6 +675,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 @@ -609,10 +691,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'); } } @@ -625,10 +707,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'); } } @@ -669,7 +751,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'); } } @@ -679,39 +761,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; @@ -720,7 +818,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'); } } @@ -729,7 +827,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'); } } @@ -738,11 +836,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) { @@ -751,16 +853,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'); } } @@ -773,7 +879,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'); } } @@ -784,8 +890,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'); } } @@ -796,8 +902,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'); } } @@ -808,8 +914,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'); } } @@ -821,8 +927,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 6cb361ffd..dc8c7291a 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 93; + private static currentVersion = 94; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -801,6 +801,323 @@ class DatabaseMigration { `); await this.updateToSchemaVersion(93); } + + // Unify database schema for all mempool netwoks + // versions above 94 should not use network-specific flags + if (databaseSchemaVersion < 94) { + + if (!isBitcoin) { + // Apply all the bitcoin specific migrations to non-bitcoin networks: liquid, liquidtestnet and testnet4 (!) + // Version 5 + await this.$executeQuery('ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"'); + + // Version 6 + await this.$executeQuery('ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`'); + await this.$executeQuery('ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL'); + await this.$executeQuery('ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)'); + await this.$executeQuery('ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""'); + await this.$executeQuery('ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL'); + + // Version 7 + await this.$executeQuery('DROP table IF EXISTS hashrates;'); + await this.$executeQuery(this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates')); + + // Version 8 + await this.$executeQuery('ALTER TABLE `hashrates` DROP INDEX `PRIMARY`'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `share` float NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"'); + + // Version 9 + await this.$executeQuery('ALTER TABLE `state` CHANGE `name` `name` varchar(100)'); + await this.$executeQuery('ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)'); + + // Version 10 + await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)'); + + // Version 11 + await this.$executeQuery(`ALTER TABLE blocks + ADD avg_fee INT UNSIGNED NULL, + ADD avg_fee_rate INT UNSIGNED NULL + `); + await this.$executeQuery('ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 12 + await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 13 + await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` DOUBLE UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee_rate` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 14 + await this.$executeQuery('ALTER TABLE `hashrates` DROP FOREIGN KEY `hashrates_ibfk_1`'); + await this.$executeQuery('ALTER TABLE `hashrates` MODIFY `pool_id` SMALLINT UNSIGNED NOT NULL DEFAULT "0"'); + + // Version 17 + await this.$executeQuery('ALTER TABLE `pools` ADD `slug` CHAR(50) NULL'); + + // Version 18 + await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `hash` (`hash`);'); + + // Version 20 + await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries')); + + // Version 22 + await this.$executeQuery('DROP TABLE IF EXISTS `difficulty_adjustments`'); + await this.$executeQuery(this.getCreateDifficultyAdjustmentsTableQuery(), await this.$checkIfTableExists('difficulty_adjustments')); + + // Version 24 + await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`'); + await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits')); + + // Version 25 + await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats')); + await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); + await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); + await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats')); + + // Version 26 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"'); + + // Version 27 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); + + // Version 28 + await this.$executeQuery(`ALTER TABLE lightning_stats MODIFY added DATE`); + + // Version 29 + await this.$executeQuery(this.getCreateGeoNamesTableQuery(), await this.$checkIfTableExists('geo_names')); + await this.$executeQuery('ALTER TABLE `nodes` ADD as_number int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD city_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD country_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD accuracy_radius int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD subdivision_id int(11) unsigned NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD longitude double NULL DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD latitude double NULL DEFAULT NULL'); + + // Version 30 + await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization") NOT NULL'); + + // Version 31 + await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE'); + await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`'); + await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices')); + + // Version 32 + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"'); + + // Version 33 + await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL'); + + // Version 34 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_tor_nodes int(11) NOT NULL DEFAULT "0"'); + + // Version 35 + await this.$executeQuery('DELETE from `lightning_stats` WHERE added > "2021-09-19"'); + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD CONSTRAINT added_unique UNIQUE (added);'); + + // Version 36 + await this.$executeQuery('ALTER TABLE `nodes` ADD status TINYINT NOT NULL DEFAULT "1"'); + + // Version 37 + await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets')); + + // Version 38 + await this.$executeQuery(`TRUNCATE lightning_stats`); + await this.$executeQuery(`TRUNCATE node_stats`); + await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL'); + await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL'); + await this.updateToSchemaVersion(38); + + // Version 39 + await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`'); + await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)'); + + // Version 40 + await this.$executeQuery('ALTER TABLE `nodes` ADD capacity bigint(20) unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);'); + + // Version 41 + await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1'); + + // Version 42 + await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0'); + + // Version 43 + await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records')); + + // Version 44 + await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); + + // Version 45 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"'); + + // Version 48 + await this.$executeQuery('ALTER TABLE `channels` ADD source_checked tinyint(1) DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD closing_fee bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node1_funding_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node2_funding_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node1_closing_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD node2_closing_balance bigint(20) unsigned DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD funding_ratio float unsigned DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `channels` ADD closed_by varchar(66) DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `channels` ADD single_funded tinyint(1) DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `channels` ADD outputs JSON DEFAULT "[]"'); + + // Version 57 + await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`); + + // Version 60 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD sigop_txs JSON DEFAULT "[]"'); + + // Version 61 + if (! await this.$checkIfTableExists('blocks_templates')) { + await this.$executeQuery('CREATE TABLE blocks_templates AS SELECT id, template FROM blocks_summaries WHERE template != "[]"'); + } + await this.$executeQuery('ALTER TABLE blocks_templates MODIFY template JSON DEFAULT "[]"'); + await this.$executeQuery('ALTER TABLE blocks_templates ADD PRIMARY KEY (id)'); + await this.$executeQuery('ALTER TABLE blocks_summaries DROP COLUMN template'); + + // Version 62 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL'); + + // Version 63 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"'); + + // Version 64 + await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL'); + + // Version 65 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD accelerated_txs JSON DEFAULT "[]"'); + + // Version 67 + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD INDEX `version` (`version`)'); + await this.$executeQuery('ALTER TABLE `blocks_templates` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)'); + + // Version 76 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD prioritized_txs JSON DEFAULT "[]"'); + + // Version 81 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD version INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `version` (`version`)'); + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD unseen_txs JSON DEFAULT "[]"'); + + // Version 83 + await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL'); + + // Version 84 + await this.$executeQuery(` + ALTER TABLE \`pools\` + ADD INDEX \`slug\` (\`slug\`), + ADD INDEX \`unique_id\` (\`unique_id\`) + `); + + // Version 85 + await this.$executeQuery(` + ALTER TABLE \`channels\` + ADD INDEX \`created\` (\`created\`), + ADD INDEX \`capacity\` (\`capacity\`), + ADD INDEX \`closing_reason\` (\`closing_reason\`), + ADD INDEX \`closing_resolved\` (\`closing_resolved\`) + `); + + // Version 86 + await this.$executeQuery(` + ALTER TABLE \`nodes\` + ADD INDEX \`status\` (\`status\`), + ADD INDEX \`channels\` (\`channels\`), + ADD INDEX \`country_id\` (\`country_id\`), + ADD INDEX \`as_number\` (\`as_number\`), + ADD INDEX \`first_seen\` (\`first_seen\`) + `); + + // Version 87 + await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)'); + await this.updateToSchemaVersion(87); + + // Version 88 + await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)'); + + // Version 89 + await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)'); + + // Version 90 + await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)'); + + // Version 91 + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)'); + } + + if (config.MEMPOOL.NETWORK !== 'liquid') { + // Apply all the liquid specific migrations to all other networks + // Version 68 + await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);'); + await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses')); + await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos')); + + // Version 71 + await this.$executeQuery('ALTER TABLE `federation_txos` ADD timelock INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `federation_txos` ADD expiredAt INT NOT NULL DEFAULT 0'); + await this.$executeQuery('ALTER TABLE `federation_txos` ADD emergencyKey TINYINT NOT NULL DEFAULT 0'); + + // Version 92 + await this.$executeQuery(` + ALTER TABLE \`elements_pegs\` + ADD INDEX \`block\` (\`block\`), + ADD INDEX \`datetime\` (\`datetime\`), + ADD INDEX \`amount\` (\`amount\`), + ADD INDEX \`bitcoinaddress\` (\`bitcoinaddress\`), + ADD INDEX \`bitcointxid\` (\`bitcointxid\`) + `); + + // Version 93 + await this.$executeQuery(` + ALTER TABLE \`federation_txos\` + ADD INDEX \`unspent\` (\`unspent\`), + ADD INDEX \`lastblockupdate\` (\`lastblockupdate\`), + ADD INDEX \`blocktime\` (\`blocktime\`), + ADD INDEX \`emergencyKey\` (\`emergencyKey\`), + ADD INDEX \`expiredAt\` (\`expiredAt\`) + `); + } + + if (config.MEMPOOL.NETWORK !== 'mainnet') { + // Apply all the mainnet specific migrations to all other networks + // Version 69 + await this.$executeQuery(this.getCreateAccelerationsTableQuery(), await this.$checkIfTableExists('accelerations')); + + // Version 70 + await this.$executeQuery('ALTER TABLE accelerations MODIFY COLUMN added DATETIME;'); + + // Version 77 + await this.$executeQuery('ALTER TABLE `accelerations` ADD requested datetime DEFAULT NULL'); + } + await this.updateToSchemaVersion(94); + } } /** diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index 8b4c3e8c8..031aeea17 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -3,6 +3,8 @@ import { Application, Request, Response } from 'express'; import channelsApi from './channels.api'; import { handleError } from '../../utils/api'; +const TXID_REGEX = /^[a-f0-9]{64}$/i; + class ChannelsRoutes { constructor() { } @@ -23,7 +25,7 @@ class ChannelsRoutes { const channels = await channelsApi.$searchChannelsById(req.params.search); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search channels by id'); } } @@ -39,7 +41,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channel); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channel'); } } @@ -70,7 +72,7 @@ class ChannelsRoutes { res.header('X-Total-Count', channelsCount.toString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channels for node'); } } @@ -83,7 +85,10 @@ class ChannelsRoutes { const txIds: string[] = []; for (const _txId in req.query.txId) { if (typeof req.query.txId[_txId] === 'string') { - txIds.push(req.query.txId[_txId].toString()); + const txid = req.query.txId[_txId].toString(); + if (TXID_REGEX.test(txid)) { + txIds.push(txid); + } } } const channels = await channelsApi.$getChannelsByTransactionId(txIds); @@ -108,7 +113,7 @@ class ChannelsRoutes { res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channels by transaction ids'); } } @@ -120,7 +125,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get penalty closed channels'); } } @@ -133,7 +138,7 @@ class ChannelsRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(channels); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get channel geodata'); } } diff --git a/backend/src/api/explorer/general.routes.ts b/backend/src/api/explorer/general.routes.ts index b4d0c635d..f974c9810 100644 --- a/backend/src/api/explorer/general.routes.ts +++ b/backend/src/api/explorer/general.routes.ts @@ -29,7 +29,7 @@ class GeneralLightningRoutes { channels: channels, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search for nodes and channels'); } } @@ -43,7 +43,7 @@ class GeneralLightningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get lightning statistics'); } } @@ -52,7 +52,7 @@ class GeneralLightningRoutes { const statistics = await statisticsApi.$getLatestStatistics(); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get lightning statistics'); } } } diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 9ca2fd1c3..811292b4b 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -32,7 +32,7 @@ class NodesRoutes { const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search); res.json(nodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to search for node'); } } @@ -188,7 +188,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(nodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get node group'); } } @@ -204,7 +204,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(node); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get node'); } } @@ -216,7 +216,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(statistics); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical node stats'); } } @@ -232,7 +232,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(node); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get fee histogram'); } } @@ -248,7 +248,7 @@ class NodesRoutes { topByChannels: topChannelsNodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes ranking'); } } @@ -260,7 +260,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get top nodes by capacity'); } } @@ -272,7 +272,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get top nodes by channels'); } } @@ -284,7 +284,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(topCapacityNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get oldest nodes'); } } @@ -296,7 +296,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(nodesPerAs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get ISP ranking'); } } @@ -308,7 +308,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(worldNodes); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get world nodes'); } } @@ -336,7 +336,7 @@ class NodesRoutes { nodes: nodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per country'); } } @@ -363,7 +363,7 @@ class NodesRoutes { nodes: nodes, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per ISP'); } } @@ -375,7 +375,7 @@ class NodesRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.json(nodesPerAs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get nodes per country'); } } } diff --git a/backend/src/api/liquid/liquid.routes.ts b/backend/src/api/liquid/liquid.routes.ts index 9dafd0def..388038f7f 100644 --- a/backend/src/api/liquid/liquid.routes.ts +++ b/backend/src/api/liquid/liquid.routes.ts @@ -83,7 +83,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.json(pegs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs by month'); } } @@ -95,7 +95,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.json(reserves); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get reserves by month'); } } @@ -107,7 +107,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(currentSupply); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs'); } } @@ -119,7 +119,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(currentReserves); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get reserves'); } } @@ -131,7 +131,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(auditStatus); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation audit status'); } } @@ -143,7 +143,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationAddresses); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation addresses'); } } @@ -155,7 +155,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationAddresses); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation addresses'); } } @@ -167,7 +167,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation utxos'); } } @@ -179,7 +179,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(expiredUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get expired utxos'); } } @@ -191,7 +191,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(federationUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get federation utxos number'); } } @@ -203,7 +203,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(emergencySpentUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get emergency spent utxos'); } } @@ -215,7 +215,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(emergencySpentUtxos); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get emergency spent utxos stats'); } } @@ -227,7 +227,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(recentPegs); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs list'); } } @@ -239,7 +239,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(pegsVolume); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs volume daily'); } } @@ -251,7 +251,7 @@ class LiquidRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.json(pegsCount); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pegs count'); } } diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 9af43c087..ede047eed 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -72,7 +72,7 @@ class MiningRoutes { } res.status(200).send(response); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical prices'); } } @@ -87,7 +87,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pool'); } } } @@ -106,7 +106,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get blocks for pool'); } } } @@ -130,7 +130,7 @@ class MiningRoutes { res.json(pools); } } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools'); } } @@ -144,7 +144,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(stats); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools'); } } @@ -158,7 +158,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(hashrates); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pools historical hashrate'); } } @@ -175,7 +175,7 @@ class MiningRoutes { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { handleError(req, res, 404, e.message); } else { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get pool historical hashrate'); } } } @@ -204,7 +204,7 @@ class MiningRoutes { currentDifficulty: currentDifficulty, }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical hashrate'); } } @@ -218,7 +218,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFees); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fees'); } } @@ -236,7 +236,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFees); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fees'); } } @@ -250,7 +250,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockRewards); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block rewards'); } } @@ -264,7 +264,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blockFeeRates); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block fee rates'); } } @@ -282,7 +282,7 @@ class MiningRoutes { weights: blockWeights }); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical block size and weight'); } } @@ -294,7 +294,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment])); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical difficulty adjustments'); } } @@ -304,7 +304,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(response); } catch (e) { - res.status(500).end(); + handleError(req, res, 500, 'Failed to get reward stats'); } } @@ -318,7 +318,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate])); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get historical blocks health'); } } @@ -336,7 +336,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.json(audit); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit'); } } @@ -359,7 +359,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get height from timestamp'); } } @@ -372,7 +372,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit scores'); } } @@ -385,7 +385,7 @@ class MiningRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.json(audit || 'null'); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get block audit score'); } } @@ -400,7 +400,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get accelerations by pool'); } } @@ -416,7 +416,7 @@ class MiningRoutes { const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get accelerations by height'); } } @@ -431,7 +431,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get recent accelerations'); } } @@ -446,7 +446,7 @@ class MiningRoutes { } res.status(200).send(await AccelerationRepository.$getAccelerationTotals(req.query.pool, req.query.interval)); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get acceleration totals'); } } @@ -461,7 +461,7 @@ class MiningRoutes { } res.status(200).send(Object.values(accelerationApi.getAccelerations() || {})); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get active accelerations'); } } @@ -473,7 +473,7 @@ class MiningRoutes { accelerationApi.accelerationRequested(req.params.txid); res.status(200).send(); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to request acceleration'); } } } diff --git a/backend/src/api/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/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 1a5ace34f..d6ac7f54f 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -612,10 +612,18 @@ 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) { + 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, cardTag, `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, costUSD @@ -743,6 +751,32 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ); } + /** + * Required in SCA Mandated Regions: Learn more at https://developer.squareup.com/docs/sca-overview + */ + async $verifyBuyer(payments, token, details, amount) { + 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.token; + } + /** * BTCPay */ 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 1b320a38a..db9345b18 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -45,14 +45,17 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { @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; @@ -120,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 }; }); } }), @@ -181,8 +184,8 @@ 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.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 = { color: [ @@ -199,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`, @@ -313,6 +316,7 @@ 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); @@ -343,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.amountShortenerPipe.transform(val, 0, undefined, true)}`; + return `$${this.amountShortenerPipe.transform(val, 3, undefined, true, true)}`; }.bind(this) }, splitLine: { @@ -399,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', @@ -413,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); @@ -430,23 +435,23 @@ 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 }; @@ -478,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++) { @@ -491,7 +496,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { i += hours - 1; } } - + return extendedSummary.reverse(); } } 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' }} + - ‎{{ 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/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 0bfcb494e..c5609882c 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -61,10 +61,7 @@ Timestamp - ‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} -
- () -
+ } @else { 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 d1764442e..c66de4caf 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -262,6 +262,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; } } } @@ -272,6 +273,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) { @@ -355,8 +359,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/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 2ecfe06ff..bec9d88a1 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -143,8 +143,8 @@ export class ServicesApiServices { return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); } - accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, userApprovedUSD: number) { - return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); + accelerateWithGooglePay$(txInput: string, token: string, verificationToken: string, cardTag: string, referenceId: string, userApprovedUSD: number) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD }); } 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/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index c6a494606..e4d0285bb 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -30,7 +30,7 @@
- +
diff --git a/frontend/src/app/shared/components/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..ce5ac0f65 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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, diff --git a/production/mempool-build-all b/production/mempool-build-all index 782ed056a..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 meta) -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=() diff --git a/production/mempool-start-all b/production/mempool-start-all index 136e984f0..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 meta;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/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 4e0b28e3e..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', @@ -282,13 +290,37 @@ export const networks = { } } }, + bitb: { + networkName: 'BITB', + title: 'BITB | Bitwise Bitcoin ETF', + description: 'BITB provides low-cost access to bitcoin through a professionally managed fund', + fallbackImg: '/resources/bitb/bitb-preview.jpg', + 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",