diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index 9407a5441..a76b93e8d 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -3,6 +3,7 @@ import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { $getRawMempool(): Promise; $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise; + $getRawTransactions(txids: string[]): Promise; $getMempoolTransactions(txids: string[]): Promise; $getAllMempoolTransactions(lastTxid: string); $getTransactionHex(txId: string): Promise; diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 807baae2e..1be7993b8 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -60,6 +60,10 @@ class BitcoinApi implements AbstractBitcoinApi { }); } + $getRawTransactions(txids: string[]): Promise { + throw new Error('Method getRawTransactions not supported by the Bitcoin RPC API.'); + } + $getMempoolTransactions(txids: string[]): Promise { throw new Error('Method getMempoolTransactions not supported by the Bitcoin RPC API.'); } diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index af021bf2e..d980ad980 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -181,7 +181,8 @@ class FailoverRouter { .catch((e) => { let fallbackHost = this.fallbackHost; if (e?.response?.status !== 404) { - logger.warn(`esplora request failed ${e?.response?.status || 500} ${host.host}${path}`); + logger.warn(`esplora request failed ${e?.response?.status} ${host.host}${path}`); + logger.warn(e instanceof Error ? e.message : e); fallbackHost = this.addFailure(host); } if (retry && e?.code === 'ECONNREFUSED' && this.multihost) { @@ -213,6 +214,10 @@ class ElectrsApi implements AbstractBitcoinApi { return this.failoverRouter.$get('/tx/' + txId); } + async $getRawTransactions(txids: string[]): Promise { + return this.failoverRouter.$post('/internal/txs', txids, 'json'); + } + async $getMempoolTransactions(txids: string[]): Promise { return this.failoverRouter.$post('/internal/mempool/txs', txids, 'json'); } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 6e1f37afb..c573d3291 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -2,6 +2,7 @@ import config from "../config"; import logger from "../logger"; import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces"; import bitcoinApi from './bitcoin/bitcoin-api-factory'; +import { IEsploraApi } from "./bitcoin/esplora-api.interface"; import { Common } from "./common"; import redisCache from "./redis-cache"; @@ -381,9 +382,11 @@ class RbfCache { this.expiring.set(expiringEntry.key, new Date(expiringEntry.value).getTime()); } }); - logger.debug(`loaded ${txs.length} txs, ${trees.length} trees into rbf cache, ${expiring.length} due to expire, ${this.staleCount} were stale`); this.staleCount = 0; + await this.checkTrees(); + logger.debug(`loaded ${txs.length} txs, ${trees.length} trees into rbf cache, ${expiring.length} due to expire, ${this.staleCount} were stale`); this.cleanup(); + } catch (e) { logger.err('failed to restore RBF cache: ' + (e instanceof Error ? e.message : e)); } @@ -421,31 +424,6 @@ class RbfCache { return; } - // check if any transactions in this tree have already been confirmed - mined = mined || treeInfo.mined; - let exists = mined; - if (!mined) { - try { - const apiTx = await bitcoinApi.$getRawTransaction(txid); - if (apiTx) { - exists = true; - } - if (apiTx?.status?.confirmed) { - mined = true; - treeInfo.txMined = true; - this.evict(txid, true); - } - } catch (e) { - // most transactions only exist in our cache - } - } - - // if the root tx is not in the mempool or the blockchain - // evict this tree as soon as possible - if (root === txid && !exists) { - this.evict(txid, true); - } - // recursively reconstruct child trees for (const childId of treeInfo.replaces) { const replaced = await this.importTree(root, childId, deflated, txs, mined); @@ -481,6 +459,59 @@ class RbfCache { return tree; } + private async checkTrees(): Promise { + const found: { [txid: string]: boolean } = {}; + const txids = Array.from(this.txs.values()).map(tx => tx.txid).filter(txid => { + return !this.expiring.has(txid) && !this.getRbfTree(txid)?.mined; + }); + + const processTxs = (txs: IEsploraApi.Transaction[]): void => { + for (const tx of txs) { + found[tx.txid] = true; + if (tx.status?.confirmed) { + const tree = this.getRbfTree(tx.txid); + if (tree) { + this.setTreeMined(tree, tx.txid); + tree.mined = true; + this.evict(tx.txid, false); + } + } + } + }; + + if (config.MEMPOOL.BACKEND === 'esplora') { + const sliceLength = 250; + for (let i = 0; i < Math.ceil(txids.length / sliceLength); i++) { + const slice = txids.slice(i * sliceLength, (i + 1) * sliceLength); + try { + const txs = await bitcoinApi.$getRawTransactions(slice); + logger.debug(`fetched ${slice.length} cached rbf transactions`); + processTxs(txs); + logger.debug(`processed ${slice.length} cached rbf transactions`); + } catch (err) { + logger.err(`failed to fetch or process ${slice.length} cached rbf transactions`); + } + } + } else { + const txs: IEsploraApi.Transaction[] = []; + for (const txid of txids) { + try { + const tx = await bitcoinApi.$getRawTransaction(txid, true, false); + txs.push(tx); + } catch (err) { + // some 404s are expected, so continue quietly + } + } + processTxs(txs); + } + + for (const txid of txids) { + if (!found[txid]) { + this.evict(txid, false); + } + } + } + public getLatestRbfSummary(): ReplacementInfo[] { const rbfList = this.getRbfTrees(false); return rbfList.slice(0, 6).map(rbfTree => {