import logger from '../logger'; import * as WebSocket from 'ws'; import { BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse, OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo, MempoolDelta, MempoolDeltaTxids, CpfpInfo } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; import backendInfo from './backend-info'; import mempoolBlocks from './mempool-blocks'; import { Common } from './common'; import loadingIndicators from './loading-indicators'; import config from '../config'; import transactionUtils from './transaction-utils'; import rbfCache, { ReplacementInfo } from './rbf-cache'; import difficultyAdjustment from './difficulty-adjustment'; import feeApi from './fee-api'; import priceUpdater from '../tasks/price-updater'; import { ApiPrice } from '../repositories/PricesRepository'; import { Acceleration } from './services/acceleration'; import accelerationApi from './services/acceleration'; import mempool from './mempool'; import statistics from './statistics/statistics'; import bitcoinApi from './bitcoin/bitcoin-api-factory'; import walletApi from './services/wallets'; interface AddressTransactions { mempool: MempoolTransactionExtended[], confirmed: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], } import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import { calculateMempoolTxCpfp } from './cpfp'; import stratumApi, { StratumJob } from './services/stratum'; // valid 'want' subscriptions const wantable = [ 'blocks', 'mempool-blocks', 'live-2h-chart', 'stats', 'tomahawk', ]; class WebsocketHandler { private webSocketServers: WebSocket.Server[] = []; private extraInitProperties = {}; private numClients = 0; private numConnected = 0; private numDisconnected = 0; private socketData: { [key: string]: string } = {}; private serializedInitData: string = '{}'; private lastRbfSummary: ReplacementInfo[] | null = null; private mempoolSequence: number = 0; private accelerations: Record = {}; private MAX_BUFFERED_AMOUNT = 10_000_000; public MAX_MESSAGE_SIZE = 50_000; private MAX_TRACKED_TXS = 100; private MSG_RATE_LIMIT = 100; private MSG_RATE_WINDOW = 10_000; constructor() { } addWebsocketServer(wss: WebSocket.Server) { this.webSocketServers.push(wss); } setExtraInitData(property: string, value: any) { this.extraInitProperties[property] = value; this.updateSocketDataFields(this.extraInitProperties); } private updateSocketDataFields(data: { [property: string]: any }): void { for (const property of Object.keys(data)) { if (data[property] != null) { this.socketData[property] = JSON.stringify(data[property]); } else { delete this.socketData[property]; } } this.serializedInitData = '{' + Object.keys(this.socketData).map(key => `"${key}": ${this.socketData[key]}`).join(', ') + '}'; } private updateSocketData(): void { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); const da = difficultyAdjustment.getDifficultyAdjustment(); this.updateSocketDataFields({ 'backend': config.MEMPOOL.BACKEND, 'mempoolInfo': memPool.getMempoolInfo(), 'vBytesPerSecond': memPool.getVBytesPerSecond(), 'blocks': _blocks, 'conversions': priceUpdater.getLatestPrices(), 'mempool-blocks': mempoolBlocks.getMempoolBlocks(), 'transactions': memPool.getLatestTransactions(), 'backendInfo': backendInfo.getBackendInfo(), 'loadingIndicators': loadingIndicators.getLoadingIndicators(), 'da': da?.previousTime ? da : undefined, 'fees': feeApi.getPreciseRecommendedFee(), }); } public getSerializedInitData(): string { return this.serializedInitData; } setupConnectionHandling() { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.on('connection', (client: WebSocket, req) => { this.numConnected++; client['remoteAddress'] = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown'; client['msgTimestamps'] = []; client.on('error', (e) => { logger.info(`websocket client error from ${client['remoteAddress']}: ` + (e instanceof Error ? e.message : e)); client.close(); }); client.on('close', () => { this.numDisconnected++; }); client.on('message', async (message) => { try { const msgLength = Buffer.isBuffer(message) ? message.byteLength : message instanceof ArrayBuffer ? message.byteLength : message.reduce((sum, buf) => sum + buf.byteLength, 0); if (msgLength > this.MAX_MESSAGE_SIZE) { logger.debug(`Dropping oversized websocket message from ${client['remoteAddress']}: ${msgLength} bytes`); client.terminate(); return; } const now = Date.now(); const timestamps: number[] = client['msgTimestamps']; timestamps.push(now); while (timestamps.length && timestamps[0] <= now - this.MSG_RATE_WINDOW) { timestamps.shift(); } if (timestamps.length > this.MSG_RATE_LIMIT) { logger.debug(`Rate limiting websocket client ${client['remoteAddress']}`); client.close(); return; } const parsedMessage: WebsocketResponse = JSON.parse(message as any); const response = {}; const wantNow = {}; if (parsedMessage && parsedMessage.action === 'want' && Array.isArray(parsedMessage.data)) { for (const sub of wantable) { const key = `want-${sub}`; const wants = parsedMessage.data.includes(sub); if (wants && !client[key]) { wantNow[key] = true; } client[key] = wants; } client['wants'] = true; } // send initial data when a client first starts a subscription if (wantNow['want-blocks'] || (parsedMessage && parsedMessage['refresh-blocks'])) { response['blocks'] = this.socketData['blocks']; } if (wantNow['want-mempool-blocks']) { response['mempool-blocks'] = this.socketData['mempool-blocks']; } if (wantNow['want-stats']) { response['mempoolInfo'] = this.socketData['mempoolInfo']; response['vBytesPerSecond'] = this.socketData['vBytesPerSecond']; response['fees'] = this.socketData['fees']; response['da'] = this.socketData['da']; } if (wantNow['want-tomahawk']) { response['tomahawk'] = JSON.stringify(bitcoinApi.getHealthStatus()); } if (parsedMessage && parsedMessage['track-tx']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { client['track-tx'] = parsedMessage['track-tx']; const trackTxid = client['track-tx']; // Client is telling the transaction wasn't found if (parsedMessage['watch-mempool']) { const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid); if (rbfCacheTxid) { response['txReplaced'] = JSON.stringify({ txid: rbfCacheTxid, }); client['track-tx'] = null; } else { // It might have appeared before we had the time to start watching for it const tx = memPool.getMempool()[trackTxid]; if (tx) { if (config.MEMPOOL.BACKEND === 'esplora') { response['tx'] = JSON.stringify(tx); } else { // tx.prevout is missing from transactions when in bitcoind mode try { const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); } } } else { try { const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true); response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); client['track-mempool-tx'] = parsedMessage['track-tx']; } } } } const tx = memPool.getMempool()[trackTxid]; if (tx && tx.position) { const position: { block: number, vsize: number, accelerated?: boolean } = { ...tx.position }; if (tx.acceleration) { position.accelerated = tx.acceleration; } response['txPosition'] = JSON.stringify({ txid: trackTxid, position, accelerationPositions: memPool.getAccelerationPositions(tx.txid), }); } } else { client['track-tx'] = null; } } if (parsedMessage && parsedMessage['track-txs']) { const txids: string[] = []; if (Array.isArray(parsedMessage['track-txs'])) { if (parsedMessage['track-txs'].length > this.MAX_TRACKED_TXS) { response['track-txs-error'] = `"too many txids requested, this connection supports tracking a maximum of ${this.MAX_TRACKED_TXS} transactions"`; this.send(client, this.serializeResponse(response)); client['track-txs'] = null; client.close(); return; } for (const txid of parsedMessage['track-txs']) { if (/^[a-fA-F0-9]{64}$/.test(txid)) { txids.push(txid); } } } else { response['track-txs-error'] = `"incorrect track-txs format"`; this.send(client, this.serializeResponse(response)); client['track-txs'] = null; client.close(); return; } const txs: { [txid: string]: TxTrackingInfo } = {}; for (const txid of txids) { const txInfo: TxTrackingInfo = {}; const rbfCacheTxid = rbfCache.getReplacedBy(txid); if (rbfCacheTxid) { txInfo.replacedBy = rbfCacheTxid; txInfo.confirmed = false; txs[txid] = txInfo; } const tx = memPool.getMempool()[txid]; if (tx) { if (tx.position) { txInfo.position = { ...tx.position }; if (tx.acceleration) { txInfo.accelerated = tx.acceleration; } } txInfo.confirmed = false; txs[txid] = txInfo; } } if (txids.length) { client['track-txs'] = txids; client['track-txs-updates'] = 0; } else { client['track-txs'] = null; client['track-txs-updates'] = 0; } if (Object.keys(txs).length) { client['track-txs-updates'] = (client['track-txs-updates'] || 0) + Object.keys(txs).length; response['tracked-txs'] = JSON.stringify(txs); } } if (parsedMessage && parsedMessage['track-address']) { const validAddress = this.testAddress(parsedMessage['track-address']); if (validAddress) { client['track-address'] = validAddress; } else { client['track-address'] = null; } } if (parsedMessage && parsedMessage['track-addresses'] && Array.isArray(parsedMessage['track-addresses'])) { const addressMap: { [address: string]: string } = {}; for (const address of parsedMessage['track-addresses']) { const validAddress = this.testAddress(address); if (validAddress) { addressMap[address] = validAddress; } } if (Object.keys(addressMap).length > config.MEMPOOL.MAX_TRACKED_ADDRESSES) { response['track-addresses-error'] = `"too many addresses requested, this connection supports tracking a maximum of ${config.MEMPOOL.MAX_TRACKED_ADDRESSES} addresses"`; client['track-addresses'] = null; client['track-addresses-updates'] = 0; } else if (Object.keys(addressMap).length > 0) { client['track-addresses'] = addressMap; client['track-addresses-updates'] = 0; } else { client['track-addresses'] = null; client['track-addresses-updates'] = 0; } } if (parsedMessage && parsedMessage['track-scriptpubkeys'] && Array.isArray(parsedMessage['track-scriptpubkeys'])) { const spks: string[] = []; for (const spk of parsedMessage['track-scriptpubkeys']) { if (/^[a-fA-F0-9]+$/.test(spk)) { spks.push(spk.toLowerCase()); } } if (spks.length > config.MEMPOOL.MAX_TRACKED_ADDRESSES) { response['track-scriptpubkeys-error'] = `"too many scriptpubkeys requested, this connection supports tracking a maximum of ${config.MEMPOOL.MAX_TRACKED_ADDRESSES} scriptpubkeys"`; client['track-scriptpubkeys'] = null; } else if (spks.length) { client['track-scriptpubkeys'] = spks; } else { client['track-scriptpubkeys'] = null; } } if (parsedMessage && parsedMessage['track-wallet']) { if (parsedMessage['track-wallet'] === 'stop') { client['track-wallet'] = null; } else if (typeof parsedMessage['track-wallet'] === 'string' && walletApi.getWallets().includes(parsedMessage['track-wallet'])) { client['track-wallet'] = parsedMessage['track-wallet']; } else { client['track-wallet'] = null; } } if (parsedMessage && parsedMessage['track-asset']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) { client['track-asset'] = parsedMessage['track-asset']; } else { client['track-asset'] = null; } } if (parsedMessage && parsedMessage['track-mempool-block'] !== undefined) { const index = parsedMessage['track-mempool-block']; if (Number.isInteger(index) && index >= 0 && index < config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) { client['track-mempool-block'] = index; const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); response['projected-block-transactions'] = JSON.stringify({ index: index, sequence: this.mempoolSequence, blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx), }); } else { client['track-mempool-block'] = null; } } if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { client['track-rbf'] = parsedMessage['track-rbf']; response['rbfLatest'] = JSON.stringify(rbfCache.getRbfTrees(parsedMessage['track-rbf'] === 'fullRbf')); } else { client['track-rbf'] = false; } } if (parsedMessage && parsedMessage['track-rbf-summary'] != null) { if (parsedMessage['track-rbf-summary']) { client['track-rbf-summary'] = true; if (this.socketData['rbfSummary'] != null) { response['rbfLatestSummary'] = this.socketData['rbfSummary']; } } else { client['track-rbf-summary'] = false; } } if (parsedMessage && parsedMessage['track-accelerations'] != null) { if (parsedMessage['track-accelerations']) { client['track-accelerations'] = true; response['accelerations'] = JSON.stringify({ accelerations: Object.values(memPool.getAccelerations()), }); } else { client['track-accelerations'] = false; } } if (parsedMessage.action === 'init') { if (!this.socketData['blocks']?.length || !this.socketData['da'] || !this.socketData['backendInfo'] || !this.socketData['conversions']) { this.updateSocketData(); } if (!this.socketData['blocks']?.length) { return; } this.send(client, this.serializedInitData); } if (parsedMessage.action === 'ping') { response['pong'] = JSON.stringify(true); } if (typeof parsedMessage['track-donation'] === 'string' && parsedMessage['track-donation'].length === 22) { client['track-donation'] = parsedMessage['track-donation']; } if (parsedMessage['track-mempool-txids'] === true) { client['track-mempool-txids'] = true; } else if (parsedMessage['track-mempool-txids'] === false) { delete client['track-mempool-txids']; } if (parsedMessage['track-mempool'] === true) { client['track-mempool'] = true; } else if (parsedMessage['track-mempool'] === false) { delete client['track-mempool']; } if (parsedMessage && parsedMessage['track-stratum'] !== undefined) { if (parsedMessage['track-stratum'] === 'all' || typeof parsedMessage['track-stratum'] === 'number') { const sub = parsedMessage['track-stratum']; client['track-stratum'] = sub; response['stratumJobs'] = JSON.stringify(stratumApi.getJobs()); } else { client['track-stratum'] = false; } } if (Object.keys(response).length) { this.send(client, this.serializeResponse(response)); } } catch (e) { logger.debug(`Error parsing websocket message from ${client['remoteAddress']}: ` + (e instanceof Error ? e.message : e)); client.close(); } }); }); } } handleNewDonation(id: string) { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } if (client['track-donation'] === id) { this.send(client, JSON.stringify({ donationConfirmed: true })); } }); } } handleLoadingChanged(indicators: ILoadingIndicators) { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } this.updateSocketDataFields({ 'loadingIndicators': indicators }); const response = JSON.stringify({ loadingIndicators: indicators }); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } this.send(client, response); }); } } handleNewConversionRates(conversionRates: ApiPrice) { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } this.updateSocketDataFields({ 'conversions': conversionRates }); const response = JSON.stringify({ conversions: conversionRates }); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } this.send(client, response); }); } } handleNewStatistic(stats: OptimizedStatistic) { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } this.printLogs(); const response = JSON.stringify({ 'live-2h-chart': stats }); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } if (!client['want-live-2h-chart']) { return; } this.send(client, response); }); } } handleAccelerationsChanged(accelerations: Record): void { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server has been set'); } const websocketAccelerationDelta = accelerationApi.getAccelerationDelta(this.accelerations, accelerations); this.accelerations = accelerations; if (!websocketAccelerationDelta.length) { return; } // pre-compute acceleration delta const accelerationUpdate = { added: websocketAccelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null), removed: websocketAccelerationDelta.filter(txid => !accelerations[txid]), }; try { const response = JSON.stringify({ accelerations: accelerationUpdate, }); for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } this.send(client, response); }); } } catch (e) { logger.debug(`Error sending acceleration update to websocket clients: ${e}`); } } handleReorg(): void { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } const da = difficultyAdjustment.getDifficultyAdjustment(); // update init data this.updateSocketDataFields({ 'blocks': blocks.getBlocks(), 'da': da?.previousTime ? da : undefined, }); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } const response = {}; if (client['want-blocks']) { response['blocks'] = this.socketData['blocks']; } if (client['want-stats']) { response['da'] = this.socketData['da']; } if (Object.keys(response).length) { this.send(client, this.serializeResponse(response)); } }); } } /** * * @param newMempool * @param mempoolSize * @param newTransactions array of transactions added this mempool update. * @param recentlyDeletedTransactions array of arrays of transactions removed in the last N mempool updates, most recent first. * @param accelerationDelta * @param candidates */ async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, newTransactions: MempoolTransactionExtended[], recentlyDeletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[], candidates?: GbtCandidates): Promise { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } this.printLogs(); const deletedTransactions = recentlyDeletedTransactions.length ? recentlyDeletedTransactions[0] : []; const transactionIds = (memPool.limitGBT && candidates) ? Object.keys(candidates?.txs || {}) : Object.keys(newMempool); let added = newTransactions; let removed = deletedTransactions; if (memPool.limitGBT) { added = candidates?.added || []; removed = candidates?.removed || []; } if (config.MEMPOOL.CLUSTER_MEMPOOL) { const cmBlocks = mempool.clusterMempool?.getBlocks(config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) ?? []; mempoolBlocks.processClusterMempoolBlocks(cmBlocks, newMempool, mempool.getAccelerations()); } else if (config.MEMPOOL.RUST_GBT) { await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, true); } else { await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, true); } const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); const mempoolInfo = memPool.getMempoolInfo(); const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, recentlyDeletedTransactions.flat()); const da = difficultyAdjustment.getDifficultyAdjustment(); const accelerations = accelerationApi.getAccelerations(); memPool.handleRbfTransactions(rbfTransactions); const rbfChanges = rbfCache.getRbfChanges(); let rbfReplacements; let fullRbfReplacements; let rbfSummary; if (Object.keys(rbfChanges.trees).length || !this.lastRbfSummary) { rbfReplacements = rbfCache.getRbfTrees(false); fullRbfReplacements = rbfCache.getRbfTrees(true); rbfSummary = rbfCache.getLatestRbfSummary() || []; this.lastRbfSummary = rbfSummary; } for (const deletedTx of deletedTransactions) { rbfCache.evict(deletedTx.txid); } memPool.removeFromSpendMap(deletedTransactions); memPool.addToSpendMap(newTransactions); const recommendedFees = feeApi.getPreciseRecommendedFee(); const latestTransactions = memPool.getLatestTransactions(); if (memPool.isInSync()) { this.mempoolSequence++; } const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; for (const tx of newTransactions) { if (rbfTransactions[tx.txid]) { for (const replaced of rbfTransactions[tx.txid].replaced) { replacedTransactions.push({ replaced: replaced.txid, by: tx }); } } } const mempoolDeltaTxids: MempoolDeltaTxids = { sequence: this.mempoolSequence, added: newTransactions.map(tx => tx.txid), removed: deletedTransactions.map(tx => tx.txid), mined: [], replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), }; const mempoolDelta: MempoolDelta = { sequence: this.mempoolSequence, added: newTransactions, removed: deletedTransactions.map(tx => tx.txid), mined: [], replaced: replacedTransactions, }; // update init data const socketDataFields = { 'mempoolInfo': mempoolInfo, 'vBytesPerSecond': vBytesPerSecond, 'mempool-blocks': mBlocks, 'transactions': latestTransactions, 'loadingIndicators': loadingIndicators.getLoadingIndicators(), 'da': da?.previousTime ? da : undefined, 'fees': recommendedFees, }; if (rbfSummary) { socketDataFields['rbfSummary'] = rbfSummary; } this.updateSocketDataFields(socketDataFields); // cache serialized objects to avoid stringify-ing the same thing for every client const responseCache = { ...this.socketData }; function getCachedResponse(key: string, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); } return responseCache[key]; } // pre-compute new tracked outspends const outspendCache: { [txid: string]: { [vout: number]: { vin: number, txid: string } } } = {}; const trackedTxs = new Set(); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client['track-tx']) { trackedTxs.add(client['track-tx']); } if (client['track-txs']) { for (const txid of client['track-txs']) { trackedTxs.add(txid); } } }); } if (trackedTxs.size > 0) { for (const tx of newTransactions) { for (let i = 0; i < tx.vin.length; i++) { const vin = tx.vin[i]; if (trackedTxs.has(vin.txid)) { if (!outspendCache[vin.txid]) { outspendCache[vin.txid] = { [vin.vout]: { vin: i, txid: tx.txid }}; } else { outspendCache[vin.txid][vin.vout] = { vin: i, txid: tx.txid }; } } } } } // pre-compute address transactions const addressCache = this.makeAddressCache(newTransactions); const removedAddressCache = this.makeAddressCache(deletedTransactions); const websocketAccelerationDelta = accelerationApi.getAccelerationDelta(this.accelerations, accelerations); this.accelerations = accelerations; // pre-compute acceleration delta const accelerationUpdate = { added: websocketAccelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null), removed: websocketAccelerationDelta.filter(txid => !accelerations[txid]), }; const cpfpUpdatesSent = new Set(); // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach(async (client) => { if (client.readyState !== WebSocket.OPEN) { return; } const response = {}; if (client['want-stats']) { response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond); response['transactions'] = getCachedResponse('transactions', latestTransactions); if (da?.previousTime) { response['da'] = getCachedResponse('da', da); } response['fees'] = getCachedResponse('fees', recommendedFees); } if (client['want-mempool-blocks']) { response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); } if (client['want-tomahawk']) { response['tomahawk'] = getCachedResponse('tomahawk', bitcoinApi.getHealthStatus()); } if (client['track-mempool-tx']) { const tx = newTransactions.find((t) => t.txid === client['track-mempool-tx']); if (tx) { if (config.MEMPOOL.BACKEND !== 'esplora') { try { const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); } } else { response['tx'] = JSON.stringify(tx); } client['track-mempool-tx'] = null; } } if (client['track-address']) { const newTransactions = Array.from(addressCache[client['track-address']]?.values() || []); const removedTransactions = Array.from(removedAddressCache[client['track-address']]?.values() || []); // txs may be missing prevouts in non-esplora backends // so fetch the full transactions now const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(newTransactions) : newTransactions; if (removedTransactions.length) { response['address-removed-transactions'] = JSON.stringify(removedTransactions); } if (fullTransactions.length) { response['address-transactions'] = JSON.stringify(fullTransactions); } } if (client['track-addresses']) { const addressMap: { [address: string]: AddressTransactions } = {}; for (const [address, key] of Object.entries(client['track-addresses'] || {})) { const newTransactions = Array.from(addressCache[key as string]?.values() || []); const removedTransactions = Array.from(removedAddressCache[key as string]?.values() || []); // txs may be missing prevouts in non-esplora backends // so fetch the full transactions now const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(newTransactions) : newTransactions; if (fullTransactions?.length) { addressMap[address] = { mempool: fullTransactions, confirmed: [], removed: removedTransactions, }; } } if (Object.keys(addressMap).length > 0) { client['track-addresses-updates'] = (client['track-addresses-updates'] || 0) + this.countAddressTransactions(addressMap); response['multi-address-transactions'] = JSON.stringify(addressMap); } } if (client['track-scriptpubkeys']) { const spkMap: { [spk: string]: AddressTransactions } = {}; for (const spk of client['track-scriptpubkeys'] || []) { const newTransactions = Array.from(addressCache[spk as string]?.values() || []); const removedTransactions = Array.from(removedAddressCache[spk as string]?.values() || []); // txs may be missing prevouts in non-esplora backends // so fetch the full transactions now const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(newTransactions) : newTransactions; if (fullTransactions?.length) { spkMap[spk] = { mempool: fullTransactions, confirmed: [], removed: removedTransactions, }; } } if (Object.keys(spkMap).length > 0) { response['multi-scriptpubkey-transactions'] = JSON.stringify(spkMap); } } if (client['track-asset']) { const foundTransactions: TransactionExtended[] = []; newTransactions.forEach((tx) => { if (client['track-asset'] === Common.nativeAssetId) { if (tx.vin.some((vin) => !!vin.is_pegin)) { foundTransactions.push(tx); return; } if (tx.vout.some((vout) => !!vout.pegout)) { foundTransactions.push(tx); } } else { if (tx.vin.some((vin) => !!vin.issuance && vin.issuance.asset_id === client['track-asset'])) { foundTransactions.push(tx); return; } if (tx.vout.some((vout) => !!vout.asset && vout.asset === client['track-asset'])) { foundTransactions.push(tx); } } }); if (foundTransactions.length) { response['address-transactions'] = JSON.stringify(foundTransactions); } } if (client['track-tx']) { const trackTxid = client['track-tx']; const outspends = outspendCache[trackTxid]; if (outspends && Object.keys(outspends).length) { response['utxoSpent'] = JSON.stringify(outspends); } const rbfReplacedBy = rbfChanges.map[client['track-tx']] ? rbfCache.getReplacedBy(client['track-tx']) : false; if (rbfReplacedBy) { response['rbfTransaction'] = JSON.stringify({ txid: rbfReplacedBy, }); } const rbfChange = rbfChanges.map[client['track-tx']]; if (rbfChange) { response['rbfInfo'] = JSON.stringify(rbfChanges.trees[rbfChange]); } const mempoolTx = newMempool[trackTxid]; if (mempoolTx && mempoolTx.position) { const positionData = { txid: trackTxid, position: { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined, feeDelta: mempoolTx.feeDelta || undefined, }, accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }; if (!mempoolTx.cpfpChecked && !mempoolTx.acceleration) { calculateMempoolTxCpfp(mempoolTx, newMempool); } if (mempoolTx.cpfpDirty) { const cpfp: CpfpInfo = { ancestors: mempoolTx.ancestors || [], bestDescendant: mempoolTx.bestDescendant || null, descendants: mempoolTx.descendants, effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize, sigops: mempoolTx.sigops, adjustedVsize: mempoolTx.adjustedVsize, acceleration: mempoolTx.acceleration, }; if (config.MEMPOOL.CLUSTER_MEMPOOL && mempoolTx.clusterId != null) { const cluster = mempool.clusterMempool?.getClusterForApi(mempoolTx.txid); if (cluster) { cpfp.cluster = cluster; } } positionData['cpfp'] = cpfp; cpfpUpdatesSent.add(trackTxid); } response['txPosition'] = JSON.stringify(positionData); } } if (client['track-txs']) { const txids = client['track-txs']; const txs: { [txid: string]: TxTrackingInfo } = {}; for (const txid of txids) { const txInfo: TxTrackingInfo = {}; let txHasInfo = false; const outspends = outspendCache[txid]; if (outspends && Object.keys(outspends).length) { txInfo.utxoSpent = outspends; txHasInfo = true; } const replacedBy = rbfChanges.map[txid] ? rbfCache.getReplacedBy(txid) : false; if (replacedBy) { txInfo.replacedBy = replacedBy; txHasInfo = true; } const mempoolTx = newMempool[txid]; if (mempoolTx && mempoolTx.position) { txInfo.position = { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined, feeDelta: mempoolTx.feeDelta || undefined, }; if (!mempoolTx.cpfpChecked) { calculateMempoolTxCpfp(mempoolTx, newMempool); } if (mempoolTx.cpfpDirty) { txInfo.cpfp = { ancestors: mempoolTx.ancestors, bestDescendant: mempoolTx.bestDescendant || null, descendants: mempoolTx.descendants, effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize, sigops: mempoolTx.sigops, adjustedVsize: mempoolTx.adjustedVsize, }; if (config.MEMPOOL.CLUSTER_MEMPOOL && mempoolTx.clusterId != null) { const cluster = mempool.clusterMempool?.getClusterForApi(mempoolTx.txid); if (cluster) { (txInfo.cpfp as CpfpInfo).cluster = cluster; } } cpfpUpdatesSent.add(txid); } txHasInfo = true; } if (txHasInfo) { txs[txid] = txInfo; } } if (Object.keys(txs).length) { client['track-txs-updates'] = (client['track-txs-updates'] || 0) + Object.keys(txs).length; response['tracked-txs'] = JSON.stringify(txs); } } if (client['track-mempool-block'] >= 0 && memPool.isInSync()) { const index = client['track-mempool-block']; if (mBlockDeltas[index]) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { index: index, sequence: this.mempoolSequence, delta: mBlockDeltas[index], }); } } if (client['track-rbf'] === 'all' && rbfReplacements) { response['rbfLatest'] = getCachedResponse('rbfLatest', rbfReplacements); } else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) { response['rbfLatest'] = getCachedResponse('fullrbfLatest', fullRbfReplacements); } if (client['track-rbf-summary'] && rbfSummary) { response['rbfLatestSummary'] = getCachedResponse('rbfLatestSummary', rbfSummary); } if (client['track-mempool-txids']) { response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); } if (client['track-mempool']) { response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); } if (client['track-accelerations'] && (accelerationUpdate.added.length || accelerationUpdate.removed.length)) { response['accelerations'] = getCachedResponse('accelerations', accelerationUpdate); } if (Object.keys(response).length) { this.send(client, this.serializeResponse(response)); } }); } for (const txid of cpfpUpdatesSent) { if (newMempool[txid]) { newMempool[txid].cpfpDirty = false; } } } /** @asyncSafe */ async handleNewBlock( block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[], rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} ): Promise { if (!this.webSocketServers.length) { throw new Error('No WebSocket.Server have been set'); } this.printLogs(); const _memPool = memPool.getMempool(); const confirmedTxids: { [txid: string]: boolean } = {}; for (const txId of txIds) { confirmedTxids[txId] = true; } const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); const da = difficultyAdjustment.getDifficultyAdjustment(); const fees = feeApi.getPreciseRecommendedFee(); const mempoolInfo = memPool.getMempoolInfo(); // pre-compute address transactions const addressCache = this.makeAddressCache(transactions); // update init data this.updateSocketDataFields({ 'mempoolInfo': mempoolInfo, 'blocks': [...blocks.getBlocks(), block].slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT), 'mempool-blocks': mBlocks, 'loadingIndicators': loadingIndicators.getLoadingIndicators(), 'da': da?.previousTime ? da : undefined, 'fees': fees, }); const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); if (memPool.isInSync()) { this.mempoolSequence++; } const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; for (const txid of Object.keys(rbfTransactions)) { for (const replaced of rbfTransactions[txid].replaced) { replacedTransactions.push({ replaced: replaced.txid, by: rbfTransactions[txid].replacedBy }); } } const mempoolDeltaTxids: MempoolDeltaTxids = { sequence: this.mempoolSequence, added: [], removed: [], mined: transactions.map(tx => tx.txid), replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), }; const mempoolDelta: MempoolDelta = { sequence: this.mempoolSequence, added: [], removed: [], mined: transactions.map(tx => tx.txid), replaced: replacedTransactions, }; // check for wallet transactions const walletTransactions = config.WALLETS.ENABLED ? walletApi.processBlock(block, transactions) : []; const responseCache = { ...this.socketData }; function getCachedResponse(key, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); } return responseCache[key]; } // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } const response = {}; if (client['want-blocks']) { response['block'] = getCachedResponse('block', block); } if (client['want-stats']) { response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond()); response['fees'] = getCachedResponse('fees', fees); if (da?.previousTime) { response['da'] = getCachedResponse('da', da); } } if (mBlocks && client['want-mempool-blocks']) { response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); } if (client['want-tomahawk']) { response['tomahawk'] = getCachedResponse('tomahawk', bitcoinApi.getHealthStatus()); } if (client['track-tx']) { const trackTxid = client['track-tx']; if (trackTxid && confirmedTxids[trackTxid]) { response['txConfirmed'] = JSON.stringify(trackTxid); } else { const mempoolTx = _memPool[trackTxid]; if (mempoolTx && mempoolTx.position) { response['txPosition'] = JSON.stringify({ txid: trackTxid, position: { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined, feeDelta: mempoolTx.feeDelta || undefined, }, accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }); } } } if (client['track-txs']) { const txs: { [txid: string]: TxTrackingInfo } = {}; for (const txid of client['track-txs']) { if (confirmedTxids[txid]) { txs[txid] = { confirmed: true }; } else { const mempoolTx = _memPool[txid]; if (mempoolTx && mempoolTx.position) { txs[txid] = { position: { ...mempoolTx.position, }, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined, feeDelta: mempoolTx.feeDelta || undefined, }; } } } if (Object.keys(txs).length) { client['track-txs-updates'] = (client['track-txs-updates'] || 0) + Object.keys(txs).length; response['tracked-txs'] = JSON.stringify(txs); } } if (client['track-address']) { const foundTransactions: TransactionExtended[] = Array.from(addressCache[client['track-address']]?.values() || []); if (foundTransactions.length) { foundTransactions.forEach((tx) => { tx.status = { confirmed: true, block_height: block.height, block_hash: block.id, block_time: block.timestamp, }; }); response['block-transactions'] = JSON.stringify(foundTransactions); } } if (client['track-addresses']) { const addressMap: { [address: string]: AddressTransactions } = {}; for (const [address, key] of Object.entries(client['track-addresses'] || {})) { const fullTransactions = Array.from(addressCache[key as string]?.values() || []); if (fullTransactions?.length) { addressMap[address] = { mempool: [], confirmed: fullTransactions, removed: [], }; } } if (Object.keys(addressMap).length > 0) { client['track-addresses-updates'] = (client['track-addresses-updates'] || 0) + this.countAddressTransactions(addressMap); response['multi-address-transactions'] = JSON.stringify(addressMap); } } if (client['track-scriptpubkeys']) { const spkMap: { [spk: string]: AddressTransactions } = {}; for (const spk of client['track-scriptpubkeys'] || []) { const fullTransactions = Array.from(addressCache[spk as string]?.values() || []); if (fullTransactions?.length) { spkMap[spk] = { mempool: [], confirmed: fullTransactions, removed: [], }; } } if (Object.keys(spkMap).length > 0) { response['multi-scriptpubkey-transactions'] = JSON.stringify(spkMap); } } if (client['track-asset']) { const foundTransactions: TransactionExtended[] = []; transactions.forEach((tx) => { if (client['track-asset'] === Common.nativeAssetId) { if (tx.vin && tx.vin.some((vin) => !!vin.is_pegin)) { foundTransactions.push(tx); return; } if (tx.vout && tx.vout.some((vout) => !!vout.pegout)) { foundTransactions.push(tx); } } else { if (tx.vin && tx.vin.some((vin) => !!vin.issuance && vin.issuance.asset_id === client['track-asset'])) { foundTransactions.push(tx); return; } if (tx.vout && tx.vout.some((vout) => !!vout.asset && vout.asset === client['track-asset'])) { foundTransactions.push(tx); } } }); if (foundTransactions.length) { foundTransactions.forEach((tx) => { tx.status = { confirmed: true, block_height: block.height, block_hash: block.id, block_time: block.timestamp, }; }); response['block-transactions'] = JSON.stringify(foundTransactions); } } if (client['track-mempool-block'] >= 0 && memPool.isInSync()) { const index = client['track-mempool-block']; if (mBlockDeltas && mBlockDeltas[index] && mBlocksWithTransactions[index]?.transactions?.length) { if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-full-${index}`, { index: index, sequence: this.mempoolSequence, blockTransactions: mBlocksWithTransactions[index].transactions.map(mempoolBlocks.compressTx), }); } else { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-delta-${index}`, { index: index, sequence: this.mempoolSequence, delta: mBlockDeltas[index], }); } } } if (client['track-mempool-txids']) { response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); } if (client['track-mempool']) { response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); } if (client['track-wallet']) { const trackedWallet = client['track-wallet']; response['wallet-transactions'] = getCachedResponse(`wallet-transactions-${trackedWallet}`, walletTransactions[trackedWallet] ?? {}); } if (Object.keys(response).length) { this.send(client, this.serializeResponse(response)); } }); } } public handleNewStratumJob(job: StratumJob): void { for (const server of this.webSocketServers) { server.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } if (client['track-stratum'] && (client['track-stratum'] === 'all' || client['track-stratum'] === job.pool)) { this.send(client, JSON.stringify({ 'stratumJob': job })); } }); } } private send(client: WebSocket.WebSocket, data: string): void { if (client.bufferedAmount > this.MAX_BUFFERED_AMOUNT) { client.terminate(); return; } client.send(data); } // takes a dictionary of JSON serialized values // and zips it together into a valid JSON object private serializeResponse(response): string { return '{' + Object.keys(response).filter(key => response[key] != null).map(key => `"${key}": ${response[key]}`).join(', ') + '}'; } // checks if an address conforms to a valid format // returns the canonical form: // - lowercase for bech32(m) // - lowercase scriptpubkey for P2PK // or false if invalid private testAddress(address): string | false { if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[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)) { 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)) { address = address.toLowerCase(); } if (/^04[a-fA-F0-9]{128}$/.test(address)) { return '41' + address + 'ac'; } else if (/^(02|03)[a-fA-F0-9]{64}$/.test(address)) { return '21' + address + 'ac'; } else { return address; } } else { return false; } } private makeAddressCache(transactions: MempoolTransactionExtended[]): { [address: string]: Set } { const addressCache: { [address: string]: Set } = {}; for (const tx of transactions) { for (const vin of tx.vin) { if (vin?.prevout?.scriptpubkey_address) { if (!addressCache[vin.prevout.scriptpubkey_address]) { addressCache[vin.prevout.scriptpubkey_address] = new Set(); } addressCache[vin.prevout.scriptpubkey_address].add(tx); } if (vin?.prevout?.scriptpubkey) { if (!addressCache[vin.prevout.scriptpubkey]) { addressCache[vin.prevout.scriptpubkey] = new Set(); } addressCache[vin.prevout.scriptpubkey].add(tx); } } for (const vout of tx.vout) { if (vout?.scriptpubkey_address) { if (!addressCache[vout?.scriptpubkey_address]) { addressCache[vout?.scriptpubkey_address] = new Set(); } addressCache[vout?.scriptpubkey_address].add(tx); } if (vout?.scriptpubkey) { if (!addressCache[vout.scriptpubkey]) { addressCache[vout.scriptpubkey] = new Set(); } addressCache[vout.scriptpubkey].add(tx); } } } return addressCache; } /** @asyncSafe */ private async getFullTransactions(transactions: MempoolTransactionExtended[]): Promise { for (let i = 0; i < transactions.length; i++) { try { transactions[i] = await transactionUtils.$getMempoolTransactionExtended(transactions[i].txid, true); } catch (e) { logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); } } return transactions; } private printLogs(): void { if (this.webSocketServers.length) { let numTxSubs = 0; let numTxsSubs = 0; let numAddressSubs = 0; let numAddressesSubs = 0; let numProjectedSubs = 0; let numRbfSubs = 0; let trackedTxsTotal = 0; let trackedAddressesTotal = 0; let trackedTxsMax = 0; let trackedAddressesMax = 0; let trackTxsTrackedTotal = 0; let trackTxsTrackedMax = 0; let trackAddressesTrackedTotal = 0; let trackAddressesTrackedMax = 0; let trackTxsUpdatesTotal = 0; let trackTxsUpdatesMax = 0; let trackAddressesUpdatesTotal = 0; let trackAddressesUpdatesMax = 0; for (const server of this.webSocketServers) { server.clients.forEach((client) => { let trackedTxCount = 0; let trackedAddressCount = 0; if (client['track-tx']) { numTxSubs++; trackedTxCount += 1; } if (client['track-txs']) { numTxsSubs++; trackedTxCount += client['track-txs'].length; } if (client['track-address']) { numAddressSubs++; trackedAddressCount += 1; } if (client['track-addresses']) { numAddressesSubs++; const addressCount = Object.keys(client['track-addresses']).length; trackedAddressCount += addressCount; trackAddressesTrackedTotal += addressCount; trackAddressesTrackedMax = Math.max(trackAddressesTrackedMax, addressCount); const updates = client['track-addresses-updates'] || 0; trackAddressesUpdatesTotal += updates; trackAddressesUpdatesMax = Math.max(trackAddressesUpdatesMax, updates); client['track-addresses-updates'] = 0; } if (client['track-mempool-block'] != null && client['track-mempool-block'] >= 0) { numProjectedSubs++; } if (client['track-rbf']) { numRbfSubs++; } if (client['track-txs']) { const txCount = client['track-txs'].length; trackTxsTrackedTotal += txCount; trackTxsTrackedMax = Math.max(trackTxsTrackedMax, txCount); const updates = client['track-txs-updates'] || 0; trackTxsUpdatesTotal += updates; trackTxsUpdatesMax = Math.max(trackTxsUpdatesMax, updates); client['track-txs-updates'] = 0; } trackedTxsTotal += trackedTxCount; trackedAddressesTotal += trackedAddressCount; trackedTxsMax = Math.max(trackedTxsMax, trackedTxCount); trackedAddressesMax = Math.max(trackedAddressesMax, trackedAddressCount); }); } let count = 0; for (const server of this.webSocketServers) { count += server.clients?.size || 0; } const diff = count - this.numClients; this.numClients = count; const trackedTxsAvg = count > 0 ? trackedTxsTotal / count : 0; const trackedAddressesAvg = count > 0 ? trackedAddressesTotal / count : 0; const trackTxsTrackedAvg = numTxsSubs > 0 ? trackTxsTrackedTotal / numTxsSubs : 0; const trackAddressesTrackedAvg = numAddressesSubs > 0 ? trackAddressesTrackedTotal / numAddressesSubs : 0; const trackTxsUpdatesAvg = numTxsSubs > 0 ? trackTxsUpdatesTotal / numTxsSubs : 0; const trackAddressesUpdatesAvg = numAddressesSubs > 0 ? trackAddressesUpdatesTotal / numAddressesSubs : 0; logger.debug( `${count} websocket clients | ${this.numConnected} connected | ${this.numDisconnected} disconnected | (${diff >= 0 ? '+' : ''}${diff}) | tracked txs: total=${trackedTxsTotal}, avg=${trackedTxsAvg.toFixed(2)}, max=${trackedTxsMax} | tracked addresses: total=${trackedAddressesTotal}, avg=${trackedAddressesAvg.toFixed(2)}, max=${trackedAddressesMax} | ws-subscriptions: tx=${numTxSubs},txs=${numTxsSubs},address=${numAddressSubs},addresses=${numAddressesSubs},txs-tracked-avg=${trackTxsTrackedAvg.toFixed(2)},txs-tracked-max=${trackTxsTrackedMax},addresses-tracked-avg=${trackAddressesTrackedAvg.toFixed(2)},addresses-tracked-max=${trackAddressesTrackedMax},txs-updates-avg=${trackTxsUpdatesAvg.toFixed(2)},txs-updates-max=${trackTxsUpdatesMax},addresses-updates-avg=${trackAddressesUpdatesAvg.toFixed(2)},addresses-updates-max=${trackAddressesUpdatesMax}` ); logger.debug(`websocket subscriptions: track-tx: ${numTxSubs}, track-txs: ${numTxsSubs}, track-address: ${numAddressSubs}, track-addresses: ${numAddressesSubs}, track-mempool-block: ${numProjectedSubs} track-rbf: ${numRbfSubs}`); this.numConnected = 0; this.numDisconnected = 0; } } private countAddressTransactions(addressMap: { [address: string]: AddressTransactions }): number { return Object.values(addressMap).reduce( (total, transactions) => total + transactions.mempool.length + transactions.confirmed.length + transactions.removed.length, 0, ); } } export default new WebsocketHandler();