diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 0dbd4fa27..91dcf35c5 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -12,14 +12,14 @@ import backendInfo from '../backend-info'; import transactionUtils from '../transaction-utils'; import { IEsploraApi } from './esplora-api.interface'; import loadingIndicators from '../loading-indicators'; -import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces'; +import { TransactionExtended } from '../../mempool.interfaces'; import logger from '../../logger'; import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; import transactionRepository from '../../repositories/TransactionRepository'; import rbfCache from '../rbf-cache'; -import { calculateMempoolTxCpfp } from '../cpfp'; +import { calculateMempoolTxCpfp, calculateLocalTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; class BitcoinRoutes { @@ -50,6 +50,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts) + .post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTx) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; @@ -829,11 +830,11 @@ class BitcoinRoutes { try { const outpoints = req.body; if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) { - return res.status(400).json({ error: 'Invalid input format' }); + return res.status(400).json({ message: 'Invalid input format' }); } if (outpoints.length > 100) { - return res.status(400).json({ error: 'Too many prevouts requested' }); + return res.status(400).json({ message: 'Too many prevouts requested' }); } const result = Array(outpoints.length).fill(null); @@ -842,12 +843,14 @@ class BitcoinRoutes { for (let i = 0; i < outpoints.length; i++) { const outpoint = outpoints[i]; let prevout: IEsploraApi.Vout | null = null; - let tx: MempoolTransactionExtended | null = null; + let unconfirmed: boolean | null = null; const mempoolTx = memPool[outpoint.txid]; if (mempoolTx) { - prevout = mempoolTx.vout[outpoint.vout]; - tx = mempoolTx; + if (outpoint.vout < mempoolTx.vout.length) { + prevout = mempoolTx.vout[outpoint.vout]; + unconfirmed = true; + } } else { const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); if (rawPrevout) { @@ -858,11 +861,12 @@ class BitcoinRoutes { scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', }; + unconfirmed = false; } } if (prevout) { - result[i] = { prevout, tx }; + result[i] = { prevout, unconfirmed }; } } @@ -872,6 +876,30 @@ class BitcoinRoutes { handleError(req, res, 500, e instanceof Error ? e.message : e); } } + + private getCpfpLocalTx(req: Request, res: Response) { + try { + const tx = req.body; + + if ( + !tx || typeof tx !== "object" || + !tx.txid || typeof tx.txid !== "string" || + typeof tx.weight !== "number" || + typeof tx.sigops !== "number" || + typeof tx.fee !== "number" || + !Array.isArray(tx.vin) || + !Array.isArray(tx.vout) + ) { + return res.status(400).json({ message: 'Invalid transaction format: missing or incorrect fields' }); + } + + const cpfpInfo = calculateLocalTxCpfp(tx, mempool.getMempool()); + res.json(cpfpInfo); + + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } } export default new BitcoinRoutes(); diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts index 9da11328b..3421d9c7a 100644 --- a/backend/src/api/cpfp.ts +++ b/backend/src/api/cpfp.ts @@ -222,6 +222,34 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: }; } + +/** + * Takes an unbroadcasted transaction and a copy of the current mempool, and calculates an estimate + * of the CPFP data if the transaction were to enter the mempool. This only returns potential ancerstors + * and effective fee rate, and does not update the CPFP data of other transactions in the cluster. + */ +export function calculateLocalTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { + const ancestorMap = new Map(); + const graphTx = convertToGraphTx(tx, memPool.getSpendMap()); + ancestorMap.set(tx.txid, graphTx); + + const allRelatives = expandRelativesGraph(mempool, ancestorMap, memPool.getSpendMap()); + const relativesMap = initializeRelatives(allRelatives); + const cluster = calculateCpfpCluster(tx.txid, relativesMap); + + let totalVsize = 0; + let totalFee = 0; + for (const tx of cluster.values()) { + totalVsize += tx.vsize; + totalFee += tx.fees.base; + } + + return { + ancestors: Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base })), + effectiveFeePerVsize: totalFee / totalVsize + } +} + /** * Given a root transaction and a list of in-mempool ancestors, * Calculate the CPFP cluster diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index ca76d0e78..b6286779a 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -51,6 +51,12 @@ } + @if (errorCpfpInfo) { +
+ Error loading CPFP data. Reason: {{ errorCpfpInfo }} +
+ } + +
@@ -188,7 +197,9 @@ @if (isLoading) {
-

Loading transaction prevouts

+

+ Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }} +

} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index c9a0c2544..441a72f61 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -13,6 +13,7 @@ import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { ApiService } from '../../services/api.service'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { CpfpInfo } from '../../interfaces/node-api.interface'; @Component({ selector: 'app-transaction-raw', @@ -23,10 +24,13 @@ export class TransactionRawComponent implements OnInit, OnDestroy { pushTxForm: UntypedFormGroup; isLoading: boolean; + isLoadingPrevouts: boolean; + isLoadingCpfpInfo: boolean; offlineMode: boolean = false; transaction: Transaction; error: string; errorPrevouts: string; + errorCpfpInfo: string; hasPrevouts: boolean; missingPrevouts: string[]; isLoadingBroadcast: boolean; @@ -46,6 +50,10 @@ export class TransactionRawComponent implements OnInit, OnDestroy { flowEnabled: boolean; adjustedVsize: number; filters: Filter[] = []; + hasEffectiveFeeRate: boolean; + fetchCpfp: boolean; + cpfpInfo: CpfpInfo | null; + hasCpfp: boolean = false; showCpfpDetails = false; ETA$: Observable; mempoolBlocksSubscription: Subscription; @@ -78,6 +86,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { try { const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); await this.fetchPrevouts(tx); + await this.fetchCpfpInfo(tx); this.processTransaction(tx); } catch (error) { this.error = error.message; @@ -100,8 +109,9 @@ export class TransactionRawComponent implements OnInit, OnDestroy { try { this.missingPrevouts = []; + this.isLoadingPrevouts = true; - const prevouts: { prevout: Vout, tx?: any }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); + const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); if (prevouts?.length !== prevoutsToFetch.length) { throw new Error(); @@ -121,27 +131,57 @@ export class TransactionRawComponent implements OnInit, OnDestroy { throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); } + transaction.fee = transaction.vin.some(input => input.is_coinbase) + ? 0 + : transaction.vin.reduce((fee, input) => { + return fee + (input.prevout?.value || 0); + }, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0); + transaction.feePerVsize = transaction.fee / (transaction.weight / 4); + transaction.sigops = countSigops(transaction); + this.hasPrevouts = true; + this.isLoadingPrevouts = false; + this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed); + } catch (error) { + this.errorPrevouts = error?.error?.message || error?.message; + this.isLoadingPrevouts = false; + } + } + + async fetchCpfpInfo(transaction: Transaction): Promise { + // Fetch potential cpfp data if all prevouts were parsed successfully and at least one of them is unconfirmed + if (this.hasPrevouts && this.fetchCpfp) { + try { + this.isLoadingCpfpInfo = true; + const cpfpInfo: CpfpInfo = await firstValueFrom(this.apiService.getCpfpLocalTx$({ + txid: transaction.txid, + weight: transaction.weight, + sigops: transaction.sigops, + fee: transaction.fee, + vin: transaction.vin, + vout: transaction.vout + })); + + if (cpfpInfo && cpfpInfo.ancestors.length > 0) { + const { ancestors, effectiveFeePerVsize } = cpfpInfo; + transaction.effectiveFeePerVsize = effectiveFeePerVsize; + this.cpfpInfo = { ancestors, effectiveFeePerVsize }; + this.hasCpfp = true; + this.hasEffectiveFeeRate = true; + } + this.isLoadingCpfpInfo = false; } catch (error) { - this.errorPrevouts = error.message; + this.errorCpfpInfo = error?.error?.message || error?.message; + this.isLoadingCpfpInfo = false; + } } } processTransaction(tx: Transaction): void { this.transaction = tx; - if (this.hasPrevouts) { - this.transaction.fee = this.transaction.vin.some(input => input.is_coinbase) - ? 0 - : this.transaction.vin.reduce((fee, input) => { - return fee + (input.prevout?.value || 0); - }, 0) - this.transaction.vout.reduce((sum, output) => sum + output.value, 0); - this.transaction.feePerVsize = this.transaction.fee / (this.transaction.weight / 4); - } - this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network); this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; - this.transaction.sigops = countSigops(this.transaction); if (this.transaction.sigops >= 0) { this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); } @@ -155,16 +195,15 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.setGraphSize(); this.ETA$ = combineLatest([ - this.stateService.mempoolTxPosition$.pipe(startWith(null)), this.stateService.mempoolBlocks$.pipe(startWith(null)), this.stateService.difficultyAdjustment$.pipe(startWith(null)), ]).pipe( - map(([position, mempoolBlocks, da]) => { + map(([mempoolBlocks, da]) => { return this.etaService.calculateETA( this.stateService.network, this.transaction, mempoolBlocks, - position, + null, da, null, null, @@ -177,7 +216,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { if (this.transaction) { this.stateService.markBlock$.next({ txid: this.transaction.txid, - txFeePerVSize: this.transaction.feePerVsize, + txFeePerVSize: this.transaction.effectiveFeePerVsize || this.transaction.feePerVsize, }); } }); @@ -214,7 +253,15 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.errorBroadcast = null; this.successBroadcast = false; this.isLoading = false; + this.isLoadingPrevouts = false; + this.isLoadingCpfpInfo = false; + this.isLoadingBroadcast = false; this.adjustedVsize = null; + this.showCpfpDetails = false; + this.hasCpfp = false; + this.fetchCpfp = false; + this.cpfpInfo = null; + this.hasEffectiveFeeRate = false; this.filters = []; this.hasPrevouts = false; this.missingPrevouts = []; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index ce0e67cbf..698eede91 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -569,6 +569,10 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints); } + getCpfpLocalTx$(tx: any): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx); + } + // Cache methods async setBlockAuditLoaded(hash: string) { this.blockAuditLoaded[hash] = true;