diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 3b416255a..b0b157c42 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -25,7 +25,8 @@ "AUTOMATIC_BLOCK_REINDEXING": false, "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", - "ADVANCED_TRANSACTION_SELECTION": false + "ADVANCED_TRANSACTION_SELECTION": false, + "TRANSACTION_INDEXING": false }, "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 ec6be20d8..8b368a43a 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -26,7 +26,8 @@ "INDEXING_BLOCKS_AMOUNT": 14, "POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__POOLS_JSON_URL__", - "ADVANCED_TRANSACTION_SELECTION": "__ADVANCED_TRANSACTION_SELECTION__" + "ADVANCED_TRANSACTION_SELECTION": "__ADVANCED_TRANSACTION_SELECTION__", + "TRANSACTION_INDEXING": "__TRANSACTION_INDEXING__" }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 9bb06c58a..c95888cf2 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -39,6 +39,7 @@ describe('Mempool Backend Config', () => { POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', ADVANCED_TRANSACTION_SELECTION: false, + TRANSACTION_INDEXING: false, }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index aa9fe5d15..7b2802d1b 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -10,7 +10,7 @@ export interface AbstractBitcoinApi { $getBlockHash(height: number): Promise; $getBlockHeader(hash: string): Promise; $getBlock(hash: string): Promise; - $getRawBlock(hash: string): Promise; + $getRawBlock(hash: string): Promise; $getAddress(address: string): Promise; $getAddressTransactions(address: string, lastSeenTxId: string): Promise; $getAddressPrefix(prefix: string): string[]; diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 0a3d674ec..cad11aeda 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -81,7 +81,7 @@ class BitcoinApi implements AbstractBitcoinApi { .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); } - $getRawBlock(hash: string): Promise { + $getRawBlock(hash: string): Promise { return this.bitcoindClient.getBlock(hash, 0) .then((raw: string) => Buffer.from(raw, "hex")); } diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index cdcc589fd..55500d0c9 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -17,13 +17,14 @@ import logger from '../../logger'; import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; +import transactionRepository from '../../repositories/TransactionRepository'; class BitcoinRoutes { public initRoutes(app: Application) { app .get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes) .get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends) - .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.getCpfpInfo) + .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks) @@ -188,29 +189,36 @@ class BitcoinRoutes { } } - private getCpfpInfo(req: Request, res: Response) { + private async $getCpfpInfo(req: Request, res: Response) { if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { res.status(501).send(`Invalid transaction ID.`); return; } const tx = mempool.getMempool()[req.params.txId]; - if (!tx) { - res.status(404).send(`Transaction doesn't exist in the mempool.`); + if (tx) { + if (tx?.cpfpChecked) { + res.json({ + ancestors: tx.ancestors, + bestDescendant: tx.bestDescendant || null, + descendants: tx.descendants || null, + effectiveFeePerVsize: tx.effectiveFeePerVsize || null, + }); + return; + } + + const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool()); + + res.json(cpfpInfo); return; + } else { + const cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); + if (cpfpInfo) { + res.json(cpfpInfo); + return; + } } - - if (tx.cpfpChecked) { - res.json({ - ancestors: tx.ancestors, - bestDescendant: tx.bestDescendant || null, - }); - return; - } - - const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool()); - - res.json(cpfpInfo); + res.status(404).send(`Transaction has no CPFP info available.`); } private getBackendInfo(req: Request, res: Response) { diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 3662347d6..37f6e5892 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -55,9 +55,9 @@ class ElectrsApi implements AbstractBitcoinApi { .then((response) => response.data); } - $getRawBlock(hash: string): Promise { - return axios.get(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", this.axiosConfig) - .then((response) => response.data); + $getRawBlock(hash: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' }) + .then((response) => { return Buffer.from(response.data); }); } $getAddress(address: string): Promise { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 411777393..8c8272262 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -21,10 +21,13 @@ import fiatConversion from './fiat-conversion'; import poolsParser from './pools-parser'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; +import cpfpRepository from '../repositories/CpfpRepository'; +import transactionRepository from '../repositories/TransactionRepository'; import mining from './mining/mining'; import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; import PricesRepository from '../repositories/PricesRepository'; import priceUpdater from '../tasks/price-updater'; +import { Block } from 'bitcoinjs-lib'; class Blocks { private blocks: BlockExtended[] = []; @@ -260,7 +263,7 @@ class Blocks { /** * [INDEXING] Index all blocks summaries for the block txs visualization */ - public async $generateBlocksSummariesDatabase() { + public async $generateBlocksSummariesDatabase(): Promise { if (Common.blocksSummariesIndexingEnabled() === false) { return; } @@ -316,6 +319,57 @@ class Blocks { } } + /** + * [INDEXING] Index transaction CPFP data for all blocks + */ + public async $generateCPFPDatabase(): Promise { + if (Common.cpfpIndexingEnabled() === false) { + return; + } + + try { + // Get all indexed block hash + const unindexedBlocks = await blocksRepository.$getCPFPUnindexedBlocks(); + + if (!unindexedBlocks?.length) { + return; + } + + // Logging + let count = 0; + let countThisRun = 0; + let timer = new Date().getTime() / 1000; + const startedAt = new Date().getTime() / 1000; + + for (const block of unindexedBlocks) { + // Logging + const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); + if (elapsedSeconds > 5) { + const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + const blockPerSeconds = Math.max(1, countThisRun / elapsedSeconds); + const progress = Math.round(count / unindexedBlocks.length * 10000) / 100; + logger.debug(`Indexing cpfp clusters for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds`); + timer = new Date().getTime() / 1000; + countThisRun = 0; + } + + await this.$indexCPFP(block.hash, block.height); // Calculate and save CPFP data for transactions in this block + + // Logging + count++; + countThisRun++; + } + if (count > 0) { + logger.notice(`CPFP indexing completed: indexed ${count} blocks`); + } else { + logger.debug(`CPFP indexing completed: indexed ${count} blocks`); + } + } catch (e) { + logger.err(`CPFP indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`); + throw e; + } + } + /** * [INDEXING] Index all blocks metadata for the mining dashboard */ @@ -359,7 +413,7 @@ class Blocks { } ++indexedThisRun; ++totalIndexed; - const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); + const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); @@ -461,9 +515,13 @@ class Blocks { await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10); await HashratesRepository.$deleteLastEntries(); await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10); + await cpfpRepository.$deleteClustersFrom(lastBlock['height'] - 10); for (let i = 10; i >= 0; --i) { const newBlock = await this.$indexBlock(lastBlock['height'] - i); await this.$getStrippedBlockTransactions(newBlock.id, true, true); + if (config.MEMPOOL.TRANSACTION_INDEXING) { + await this.$indexCPFP(newBlock.id, lastBlock['height'] - i); + } } await mining.$indexDifficultyAdjustments(); await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); @@ -489,6 +547,9 @@ class Blocks { if (Common.blocksSummariesIndexingEnabled() === true) { await this.$getStrippedBlockTransactions(blockExtended.id, true); } + if (config.MEMPOOL.TRANSACTION_INDEXING) { + this.$indexCPFP(blockExtended.id, this.currentBlockHeight); + } } } @@ -678,6 +739,62 @@ class Blocks { public getCurrentBlockHeight(): number { return this.currentBlockHeight; } + + public async $indexCPFP(hash: string, height: number): Promise { + let transactions; + if (false/*Common.blocksSummariesIndexingEnabled()*/) { + transactions = await this.$getStrippedBlockTransactions(hash); + const rawBlock = await bitcoinApi.$getRawBlock(hash); + const block = Block.fromBuffer(rawBlock); + const txMap = {}; + for (const tx of block.transactions || []) { + txMap[tx.getId()] = tx; + } + for (const tx of transactions) { + if (txMap[tx.txid]?.ins) { + tx.vin = txMap[tx.txid].ins.map(vin => { + return { + txid: vin.hash + }; + }); + } + } + } else { + const block = await bitcoinClient.getBlock(hash, 2); + transactions = block.tx.map(tx => { + tx.vsize = tx.weight / 4; + return tx; + }); + } + + let cluster: TransactionStripped[] = []; + let ancestors: { [txid: string]: boolean } = {}; + for (let i = transactions.length - 1; i >= 0; i--) { + const tx = transactions[i]; + if (!ancestors[tx.txid]) { + let totalFee = 0; + let totalVSize = 0; + cluster.forEach(tx => { + totalFee += tx?.fee || 0; + totalVSize += tx.vsize; + }); + const effectiveFeePerVsize = (totalFee * 100_000_000) / totalVSize; + if (cluster.length > 1) { + await cpfpRepository.$saveCluster(height, cluster.map(tx => { return { txid: tx.txid, weight: tx.vsize * 4, fee: (tx.fee || 0) * 100_000_000 }; }), effectiveFeePerVsize); + for (const tx of cluster) { + await transactionRepository.$setCluster(tx.txid, cluster[0].txid); + } + } + cluster = []; + ancestors = {}; + } + cluster.push(tx); + tx.vin.forEach(vin => { + ancestors[vin.txid] = true; + }); + } + await blocksRepository.$setCPFPIndexed(hash); + } } export default new Blocks(); diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index b9cc1453c..621f021ba 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -187,6 +187,13 @@ export class Common { ); } + static cpfpIndexingEnabled(): boolean { + return ( + Common.indexingEnabled() && + config.MEMPOOL.TRANSACTION_INDEXING === true + ); + } + static setDateMidnight(date: Date): void { date.setUTCHours(0); date.setUTCMinutes(0); diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 491613ef8..a9cb14929 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 46; + private static currentVersion = 47; private queryTimeout = 900_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -373,7 +373,13 @@ class DatabaseMigration { if (databaseSchemaVersion < 46) { await this.$executeQuery(`ALTER TABLE blocks MODIFY blockTimestamp timestamp NOT NULL DEFAULT 0`); } - } + + if (databaseSchemaVersion < 47) { + await this.$executeQuery('ALTER TABLE `blocks` ADD cpfp_indexed tinyint(1) DEFAULT 0'); + await this.$executeQuery(this.getCreateCPFPTableQuery(), await this.$checkIfTableExists('cpfp_clusters')); + await this.$executeQuery(this.getCreateTransactionsTableQuery(), await this.$checkIfTableExists('transactions')); + } +} /** * Special case here for the `statistics` table - It appeared that somehow some dbs already had the `added` field indexed @@ -821,6 +827,25 @@ class DatabaseMigration { ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } + private getCreateCPFPTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS cpfp_clusters ( + root varchar(65) NOT NULL, + height int(10) NOT NULL, + txs JSON DEFAULT NULL, + fee_rate double unsigned NOT NULL, + PRIMARY KEY (root) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + private getCreateTransactionsTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS transactions ( + txid varchar(65) NOT NULL, + cluster varchar(65) DEFAULT NULL, + PRIMARY KEY (txid), + FOREIGN KEY (cluster) REFERENCES cpfp_clusters (root) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + public async $truncateIndexedData(tables: string[]) { const allowedTables = ['blocks', 'hashrates', 'prices']; diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 334362458..e8ab48230 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -155,6 +155,7 @@ class MempoolBlocks { if (newMempool[txid] && mempool[txid]) { newMempool[txid].effectiveFeePerVsize = mempool[txid].effectiveFeePerVsize; newMempool[txid].ancestors = mempool[txid].ancestors; + newMempool[txid].descendants = mempool[txid].descendants; newMempool[txid].bestDescendant = mempool[txid].bestDescendant; newMempool[txid].cpfpChecked = mempool[txid].cpfpChecked; } diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts index 10f65000b..206d26fe3 100644 --- a/backend/src/api/tx-selection-worker.ts +++ b/backend/src/api/tx-selection-worker.ts @@ -108,36 +108,38 @@ function makeBlockTemplates({ mempool, blockLimit, weightLimit, condenseRest }: if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { blockWeight += nextTx.ancestorWeight; const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); + const descendants: AuditTransaction[] = []; // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); - sortedTxSet.forEach((ancestor, i, arr) => { + + while (sortedTxSet.length) { + const ancestor = sortedTxSet.pop(); const mempoolTx = mempool[ancestor.txid]; if (ancestor && !ancestor?.used) { ancestor.used = true; // update original copy of this tx with effective fee rate & relatives data mempoolTx.effectiveFeePerVsize = effectiveFeeRate; - mempoolTx.ancestors = (Array.from(ancestor.ancestorMap?.values()) as AuditTransaction[]).map((a) => { + mempoolTx.ancestors = sortedTxSet.map((a) => { + return { + txid: a.txid, + fee: a.fee, + weight: a.weight, + }; + }).reverse(); + mempoolTx.descendants = descendants.map((a) => { return { txid: a.txid, fee: a.fee, weight: a.weight, }; }); + descendants.push(ancestor); mempoolTx.cpfpChecked = true; - if (i < arr.length - 1) { - mempoolTx.bestDescendant = { - txid: arr[arr.length - 1].txid, - fee: arr[arr.length - 1].fee, - weight: arr[arr.length - 1].weight, - }; - } else { - mempoolTx.bestDescendant = null; - } transactions.push(ancestor); blockSize += ancestor.size; } - }); + } // remove these as valid package ancestors for any descendants remaining in the mempool if (sortedTxSet.length) { diff --git a/backend/src/config.ts b/backend/src/config.ts index f7d1ee60a..808e1406b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -30,6 +30,7 @@ interface IConfig { POOLS_JSON_URL: string, POOLS_JSON_TREE_URL: string, ADVANCED_TRANSACTION_SELECTION: boolean; + TRANSACTION_INDEXING: boolean; }; ESPLORA: { REST_API_URL: string; @@ -148,6 +149,7 @@ const defaults: IConfig = { 'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', 'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', 'ADVANCED_TRANSACTION_SELECTION': false, + 'TRANSACTION_INDEXING': false, }, 'ESPLORA': { 'REST_API_URL': 'http://127.0.0.1:3000', diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index 26a407291..22f3ce319 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -77,6 +77,7 @@ class Indexer { await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); await blocks.$generateBlocksSummariesDatabase(); + await blocks.$generateCPFPDatabase(); } catch (e) { this.indexerRunning = false; logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 0e68d2ed5..11de304b8 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -72,6 +72,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction { firstSeen?: number; effectiveFeePerVsize: number; ancestors?: Ancestor[]; + descendants?: Ancestor[]; bestDescendant?: BestDescendant | null; cpfpChecked?: boolean; deleteAfter?: number; @@ -119,7 +120,9 @@ interface BestDescendant { export interface CpfpInfo { ancestors: Ancestor[]; - bestDescendant: BestDescendant | null; + bestDescendant?: BestDescendant | null; + descendants?: Ancestor[]; + effectiveFeePerVsize?: number; } export interface TransactionStripped { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 590e9de37..78a8fcce2 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -662,6 +662,23 @@ class BlocksRepository { } } + /** + * Get a list of blocks that have not had CPFP data indexed + */ + public async $getCPFPUnindexedBlocks(): Promise { + try { + const [rows]: any = await DB.query(`SELECT height, hash FROM blocks WHERE cpfp_indexed = 0 ORDER BY height DESC`); + return rows; + } catch (e) { + logger.err('Cannot fetch CPFP unindexed blocks. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $setCPFPIndexed(hash: string): Promise { + await DB.query(`UPDATE blocks SET cpfp_indexed = 1 WHERE hash = ?`, [hash]); + } + /** * Return the oldest block from a consecutive chain of block from the most recent one */ diff --git a/backend/src/repositories/CpfpRepository.ts b/backend/src/repositories/CpfpRepository.ts new file mode 100644 index 000000000..563e6ede1 --- /dev/null +++ b/backend/src/repositories/CpfpRepository.ts @@ -0,0 +1,43 @@ +import DB from '../database'; +import logger from '../logger'; +import { Ancestor } from '../mempool.interfaces'; + +class CpfpRepository { + public async $saveCluster(height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise { + try { + const txsJson = JSON.stringify(txs); + await DB.query( + ` + INSERT INTO cpfp_clusters(root, height, txs, fee_rate) + VALUE (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + height = ?, + txs = ?, + fee_rate = ? + `, + [txs[0].txid, height, txsJson, effectiveFeePerVsize, height, txsJson, effectiveFeePerVsize, height] + ); + } catch (e: any) { + logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $deleteClustersFrom(height: number): Promise { + logger.info(`Delete newer cpfp clusters from height ${height} from the database`); + try { + await DB.query( + ` + DELETE from cpfp_clusters + WHERE height >= ? + `, + [height] + ); + } catch (e: any) { + logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } +} + +export default new CpfpRepository(); \ No newline at end of file diff --git a/backend/src/repositories/TransactionRepository.ts b/backend/src/repositories/TransactionRepository.ts new file mode 100644 index 000000000..1c6e3719f --- /dev/null +++ b/backend/src/repositories/TransactionRepository.ts @@ -0,0 +1,77 @@ +import DB from '../database'; +import logger from '../logger'; +import { Ancestor, CpfpInfo } from '../mempool.interfaces'; + +interface CpfpSummary { + txid: string; + cluster: string; + root: string; + txs: Ancestor[]; + height: number; + fee_rate: number; +} + +class TransactionRepository { + public async $setCluster(txid: string, cluster: string): Promise { + try { + await DB.query( + ` + INSERT INTO transactions + ( + txid, + cluster + ) + VALUE (?, ?) + ON DUPLICATE KEY UPDATE + cluster = ? + ;`, + [txid, cluster, cluster] + ); + } catch (e: any) { + logger.err(`Cannot save transaction cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getCpfpInfo(txid: string): Promise { + try { + let query = ` + SELECT * + FROM transactions + LEFT JOIN cpfp_clusters AS cluster ON cluster.root = transactions.cluster + WHERE transactions.txid = ? + `; + const [rows]: any = await DB.query(query, [txid]); + if (rows.length) { + rows[0].txs = JSON.parse(rows[0].txs) as Ancestor[]; + return this.convertCpfp(rows[0]); + } + } catch (e) { + logger.err('Cannot get transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + private convertCpfp(cpfp: CpfpSummary): CpfpInfo { + const descendants: Ancestor[] = []; + const ancestors: Ancestor[] = []; + let matched = false; + for (const tx of cpfp.txs) { + if (tx.txid === cpfp.txid) { + matched = true; + } else if (!matched) { + descendants.push(tx); + } else { + ancestors.push(tx); + } + } + return { + descendants, + ancestors, + effectiveFeePerVsize: cpfp.fee_rate + }; + } +} + +export default new TransactionRepository(); + diff --git a/frontend/src/app/components/transaction/transaction-preview.component.html b/frontend/src/app/components/transaction/transaction-preview.component.html index 40ef94dde..8feb980a3 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.html +++ b/frontend/src/app/components/transaction/transaction-preview.component.html @@ -8,10 +8,10 @@
- + CPFP - + CPFP
diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts index 6c04af0ab..bd4260244 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.ts +++ b/frontend/src/app/components/transaction/transaction-preview.component.ts @@ -72,25 +72,31 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { if (!this.tx) { return; } - const lowerFeeParents = cpfpInfo.ancestors.filter( - (parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize - ); - let totalWeight = - this.tx.weight + - lowerFeeParents.reduce((prev, val) => prev + val.weight, 0); - let totalFees = - this.tx.fee + - lowerFeeParents.reduce((prev, val) => prev + val.fee, 0); + if (cpfpInfo.effectiveFeePerVsize) { + this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize; + } else { + const lowerFeeParents = cpfpInfo.ancestors.filter( + (parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize + ); + let totalWeight = + this.tx.weight + + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0); + let totalFees = + this.tx.fee + + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0); - if (cpfpInfo.bestDescendant) { - totalWeight += cpfpInfo.bestDescendant.weight; - totalFees += cpfpInfo.bestDescendant.fee; + if (cpfpInfo?.bestDescendant) { + totalWeight += cpfpInfo?.bestDescendant.weight; + totalFees += cpfpInfo?.bestDescendant.fee; + } + + this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); + } + if (!this.tx.status.confirmed) { + this.stateService.markBlock$.next({ + txFeePerVSize: this.tx.effectiveFeePerVsize, + }); } - - this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); - this.stateService.markBlock$.next({ - txFeePerVSize: this.tx.effectiveFeePerVsize, - }); this.cpfpInfo = cpfpInfo; this.openGraphService.waitOver('cpfp-data-' + this.txId); }); @@ -176,8 +182,17 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { this.getTransactionTime(); } - if (!this.tx.status.confirmed) { + if (this.tx.status.confirmed) { + this.stateService.markBlock$.next({ + blockHeight: tx.status.block_height, + }); + this.openGraphService.waitFor('cpfp-data-' + this.txId); + this.fetchCpfp$.next(this.tx.txid); + } else { if (tx.cpfpChecked) { + this.stateService.markBlock$.next({ + txFeePerVSize: tx.effectiveFeePerVsize, + }); this.cpfpInfo = { ancestors: tx.ancestors, bestDescendant: tx.bestDescendant, diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index d478da053..b6ed2868f 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -156,7 +156,20 @@ - + + + Descendant + + {{ cpfpTx.txid | shortenString : 8 }} + {{ cpfpTx.txid }} + + + + {{ cpfpTx.fee / (cpfpTx.weight / 4) | feeRounding }} sat/vB + + + + Descendant @@ -170,7 +183,7 @@ - + Ancestor @@ -468,11 +481,11 @@ {{ tx.feePerVsize | feeRounding }} sat/vB   - + - + Effective fee rate
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 1c20be732..3e04b0ad9 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -117,25 +117,31 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (!this.tx) { return; } - const lowerFeeParents = cpfpInfo.ancestors.filter( - (parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize - ); - let totalWeight = - this.tx.weight + - lowerFeeParents.reduce((prev, val) => prev + val.weight, 0); - let totalFees = - this.tx.fee + - lowerFeeParents.reduce((prev, val) => prev + val.fee, 0); + if (cpfpInfo.effectiveFeePerVsize) { + this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize; + } else { + const lowerFeeParents = cpfpInfo.ancestors.filter( + (parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize + ); + let totalWeight = + this.tx.weight + + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0); + let totalFees = + this.tx.fee + + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0); - if (cpfpInfo.bestDescendant) { - totalWeight += cpfpInfo.bestDescendant.weight; - totalFees += cpfpInfo.bestDescendant.fee; + if (cpfpInfo?.bestDescendant) { + totalWeight += cpfpInfo?.bestDescendant.weight; + totalFees += cpfpInfo?.bestDescendant.fee; + } + + this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); + } + if (!this.tx.status.confirmed) { + this.stateService.markBlock$.next({ + txFeePerVSize: this.tx.effectiveFeePerVsize, + }); } - - this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); - this.stateService.markBlock$.next({ - txFeePerVSize: this.tx.effectiveFeePerVsize, - }); this.cpfpInfo = cpfpInfo; }); @@ -239,6 +245,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.stateService.markBlock$.next({ blockHeight: tx.status.block_height, }); + this.fetchCpfp$.next(this.tx.txid); } else { if (tx.cpfpChecked) { this.stateService.markBlock$.next({ diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 5df095432..d32e641f7 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -22,7 +22,9 @@ interface BestDescendant { export interface CpfpInfo { ancestors: Ancestor[]; - bestDescendant: BestDescendant | null; + descendants?: Ancestor[]; + bestDescendant?: BestDescendant | null; + effectiveFeePerVsize?: number; } export interface DifficultyAdjustment {