diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index f053180b0..71a3d9d53 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -7,14 +7,17 @@ import logger from '../logger'; import config from '../config'; import { TransactionExtended } from '../mempool.interfaces'; import { Common } from './common'; +import rbfCache from './rbf-cache'; class DiskCache { private cacheSchemaVersion = 3; + private rbfCacheSchemaVersion = 1; private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json'; private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json'; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; + private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json'; private static CHUNK_FILES = 25; private isWritingCache = false; @@ -100,6 +103,20 @@ class DiskCache { logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e)); this.isWritingCache = false; } + try { + logger.debug('Writing rbf data to disk cache (async)...'); + this.isWritingCache = true; + + await fsPromises.writeFile(DiskCache.RBF_FILE_NAME, JSON.stringify({ + rbfCacheSchemaVersion: this.rbfCacheSchemaVersion, + rbf: rbfCache.dump(), + }), { flag: 'w' }); + logger.debug('Rbf data saved to disk cache'); + this.isWritingCache = false; + } catch (e) { + logger.warn('Error writing rbf data to cache file: ' + (e instanceof Error ? e.message : e)); + this.isWritingCache = false; + } } wipeCache(): void { @@ -124,6 +141,18 @@ class DiskCache { } } + wipeRbfCache() { + logger.notice(`Wipping nodejs backend cache/rbfcache.json file`); + + try { + fs.unlinkSync(DiskCache.RBF_FILE_NAME); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + logger.err(`Cannot wipe cache file ${DiskCache.RBF_FILE_NAME}. Exception ${JSON.stringify(e)}`); + } + } + } + async $loadMempoolCache(): Promise { if (!fs.existsSync(DiskCache.FILE_NAME)) { return; @@ -174,6 +203,23 @@ class DiskCache { } catch (e) { logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); } + + try { + let rbfData: any = {}; + const rbfCacheData = fs.readFileSync(DiskCache.RBF_FILE_NAME, 'utf8'); + if (rbfCacheData) { + logger.info('Restoring rbf data from disk cache'); + rbfData = JSON.parse(rbfCacheData); + if (rbfData.rbfCacheSchemaVersion === undefined || rbfData.rbfCacheSchemaVersion !== this.rbfCacheSchemaVersion) { + logger.notice('Rbf disk cache contains an outdated schema version. Clearing it and skipping the cache loading.'); + return this.wipeRbfCache(); + } + } + + rbfCache.load(rbfData.rbf); + } catch (e) { + logger.warn('Failed to parse rbf cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); + } } } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 3377999f8..7f910e0f7 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,5 +1,5 @@ -import { runInNewContext } from "vm"; import { TransactionExtended, TransactionStripped } from "../mempool.interfaces"; +import bitcoinApi from './bitcoin/bitcoin-api-factory'; import { Common } from "./common"; interface RbfTransaction extends TransactionStripped { @@ -218,6 +218,104 @@ class RbfCache { }); } } + + public dump(): any { + const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); }); + + return { + txs: Array.from(this.txs.entries()), + trees, + expiring: Array.from(this.expiring.entries()), + }; + } + + public async load({ txs, trees, expiring }): Promise { + txs.forEach(txEntry => { + this.txs.set(txEntry[0], txEntry[1]); + }); + for (const deflatedTree of trees) { + await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); + } + expiring.forEach(expiringEntry => { + this.expiring.set(expiringEntry[0], expiringEntry[1]); + }); + this.cleanup(); + } + + exportTree(tree: RbfTree, deflated: any = null) { + if (!deflated) { + deflated = { + root: tree.tx.txid, + }; + } + deflated[tree.tx.txid] = { + tx: tree.tx.txid, + txMined: tree.tx.mined, + time: tree.time, + interval: tree.interval, + mined: tree.mined, + fullRbf: tree.fullRbf, + replaces: tree.replaces.map(child => child.tx.txid), + }; + tree.replaces.forEach(child => { + this.exportTree(child, deflated); + }); + return deflated; + } + + async importTree(root, txid, deflated, txs: Map, mined: boolean = false): Promise { + const treeInfo = deflated[txid]; + const replaces: RbfTree[] = []; + + // check if any transactions in this tree have already been confirmed + mined = mined || treeInfo.mined; + if (!mined) { + try { + const apiTx = await bitcoinApi.$getRawTransaction(txid); + if (apiTx?.status?.confirmed) { + mined = true; + this.evict(txid); + } + } catch (e) { + // most transactions do not exist + } + } + + // recursively reconstruct child trees + for (const childId of treeInfo.replaces) { + const replaced = await this.importTree(root, childId, deflated, txs, mined); + if (replaced) { + this.replacedBy.set(replaced.tx.txid, txid); + replaces.push(replaced); + if (replaced.mined) { + mined = true; + } + } + } + this.replaces.set(txid, replaces.map(t => t.tx.txid)); + + const tx = txs.get(txid); + if (!tx) { + return; + } + const strippedTx = Common.stripTransaction(tx) as RbfTransaction; + strippedTx.rbf = tx.vin.some((v) => v.sequence < 0xfffffffe); + strippedTx.mined = treeInfo.txMined; + const tree = { + tx: strippedTx, + time: treeInfo.time, + interval: treeInfo.interval, + mined: mined, + fullRbf: treeInfo.fullRbf, + replaces, + }; + this.treeMap.set(txid, root); + if (root === txid) { + this.rbfTrees.set(root, tree); + this.dirtyTrees.add(root); + } + return tree; + } } export default new RbfCache();