diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 76b27d630..3cb79b909 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -33,7 +33,8 @@ "DISK_CACHE_BLOCK_INTERVAL": 6, "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, "ALLOW_UNREACHABLE": true, - "PRICE_UPDATES_PER_HOUR": 1 + "PRICE_UPDATES_PER_HOUR": 1, + "MAX_TRACKED_ADDRESSES": 100 }, "CORE_RPC": { "HOST": "127.0.0.1", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 0c30651ce..9445fc25d 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -34,7 +34,8 @@ "DISK_CACHE_BLOCK_INTERVAL": 999, "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, "ALLOW_UNREACHABLE": true, - "PRICE_UPDATES_PER_HOUR": 1 + "PRICE_UPDATES_PER_HOUR": 1, + "MAX_TRACKED_ADDRESSES": 1 }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 2991162e9..97c218370 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -48,6 +48,7 @@ describe('Mempool Backend Config', () => { MAX_PUSH_TX_SIZE_WEIGHT: 400000, ALLOW_UNREACHABLE: true, PRICE_UPDATES_PER_HOUR: 1, + MAX_TRACKED_ADDRESSES: 1, }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 5213a6bee..d0e0b7fd8 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -24,6 +24,12 @@ import { ApiPrice } from '../repositories/PricesRepository'; import accelerationApi from './services/acceleration'; import mempool from './mempool'; +interface AddressTransactions { + mempool: MempoolTransactionExtended[], + confirmed: MempoolTransactionExtended[], + removed: MempoolTransactionExtended[], +} + // valid 'want' subscriptions const wantable = [ 'blocks', @@ -195,24 +201,49 @@ class WebsocketHandler { } if (parsedMessage && parsedMessage['track-address']) { - 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(parsedMessage['track-address'])) { - let matchedAddress = parsedMessage['track-address']; - if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(parsedMessage['track-address'])) { - matchedAddress = matchedAddress.toLowerCase(); - } - if (/^04[a-fA-F0-9]{128}$/.test(parsedMessage['track-address'])) { - client['track-address'] = '41' + matchedAddress + 'ac'; - } else if (/^(02|03)[a-fA-F0-9]{64}$/.test(parsedMessage['track-address'])) { - client['track-address'] = '21' + matchedAddress + 'ac'; - } else { - client['track-address'] = matchedAddress; - } + 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; + } else if (Object.keys(addressMap).length > 0) { + client['track-addresses'] = addressMap; + } else { + client['track-addresses'] = null; + } + } + + 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-asset']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) { client['track-asset'] = parsedMessage['track-asset']; @@ -544,6 +575,50 @@ class WebsocketHandler { } } + 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) { + 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[] = []; @@ -844,6 +919,42 @@ class WebsocketHandler { } } + 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) { + 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[] = []; @@ -913,6 +1024,28 @@ class WebsocketHandler { + '}'; } + // 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) { diff --git a/backend/src/config.ts b/backend/src/config.ts index 4115149e6..df1022a67 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -39,6 +39,7 @@ interface IConfig { MAX_PUSH_TX_SIZE_WEIGHT: number; ALLOW_UNREACHABLE: boolean; PRICE_UPDATES_PER_HOUR: number; + MAX_TRACKED_ADDRESSES: number; }; ESPLORA: { REST_API_URL: string; @@ -193,6 +194,7 @@ const defaults: IConfig = { 'MAX_PUSH_TX_SIZE_WEIGHT': 400000, 'ALLOW_UNREACHABLE': true, 'PRICE_UPDATES_PER_HOUR': 1, + 'MAX_TRACKED_ADDRESSES': 1, }, 'ESPLORA': { 'REST_API_URL': 'http://127.0.0.1:3000', diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index e73fa1929..c68e37baa 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -35,6 +35,7 @@ "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__ + "MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__ }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/docker/backend/start.sh b/docker/backend/start.sh index 232cf7284..d73ea83fb 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -36,6 +36,7 @@ __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__=${MEMPOOL_DISK_CACHE_BLOCK_INTERVAL:=6} __MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__=${MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT:=4000000} __MEMPOOL_ALLOW_UNREACHABLE__=${MEMPOOL_ALLOW_UNREACHABLE:=true} __MEMPOOL_PRICE_UPDATES_PER_HOUR__=${MEMPOOL_PRICE_UPDATES_PER_HOUR:=1} +__MEMPOOL_MAX_TRACKED_ADDRESSES__=${MEMPOOL_MAX_TRACKED_ADDRESSES:=1} # CORE_RPC __CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1} @@ -188,6 +189,7 @@ sed -i "s!__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__!${__MEMPOOL_DISK_CACHE_BLOCK_INT sed -i "s!__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__!${__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__}!g" mempool-config.json sed -i "s!__MEMPOOL_ALLOW_UNREACHABLE__!${__MEMPOOL_ALLOW_UNREACHABLE__}!g" mempool-config.json sed -i "s!__MEMPOOL_PRICE_UPDATES_PER_HOUR__!${__MEMPOOL_PRICE_UPDATES_PER_HOUR__}!g" mempool-config.json +sed -i "s!__MEMPOOL_MAX_TRACKED_ADDRESSES__!${__MEMPOOL_MAX_TRACKED_ADDRESSES__}!g" mempool-config.json sed -i "s!__CORE_RPC_HOST__!${__CORE_RPC_HOST__}!g" mempool-config.json sed -i "s!__CORE_RPC_PORT__!${__CORE_RPC_PORT__}!g" mempool-config.json diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 20bc42bde..35bcbe9cc 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -27,6 +27,8 @@ export interface WebsocketResponse { fees?: Recommendedfees; 'track-tx'?: string; 'track-address'?: string; + 'track-addresses'?: string[]; + 'track-scriptpubkeys'?: string[]; 'track-asset'?: string; 'track-mempool-block'?: number; 'track-rbf'?: string;