diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index d2d298e09..0dbd4fa27 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -12,7 +12,7 @@ import backendInfo from '../backend-info'; import transactionUtils from '../transaction-utils'; import { IEsploraApi } from './esplora-api.interface'; import loadingIndicators from '../loading-indicators'; -import { TransactionExtended } from '../../mempool.interfaces'; +import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces'; import logger from '../../logger'; import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; @@ -49,6 +49,7 @@ class BitcoinRoutes { .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .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) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; @@ -824,6 +825,53 @@ class BitcoinRoutes { } } + private async $getPrevouts(req: Request, res: Response) { + 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' }); + } + + if (outpoints.length > 100) { + return res.status(400).json({ error: 'Too many prevouts requested' }); + } + + const result = Array(outpoints.length).fill(null); + const memPool = mempool.getMempool(); + + for (let i = 0; i < outpoints.length; i++) { + const outpoint = outpoints[i]; + let prevout: IEsploraApi.Vout | null = null; + let tx: MempoolTransactionExtended | null = null; + + const mempoolTx = memPool[outpoint.txid]; + if (mempoolTx) { + prevout = mempoolTx.vout[outpoint.vout]; + tx = mempoolTx; + } else { + const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); + if (rawPrevout) { + prevout = { + value: Math.round(rawPrevout.value * 100000000), + scriptpubkey: rawPrevout.scriptPubKey.hex, + scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '', + scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), + scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', + }; + } + } + + if (prevout) { + result[i] = { prevout, tx }; + } + } + + res.json(result); + + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } } export default new BitcoinRoutes(); diff --git a/backend/src/api/transaction-utils.ts b/backend/src/api/transaction-utils.ts index 28fa72bba..519527d5c 100644 --- a/backend/src/api/transaction-utils.ts +++ b/backend/src/api/transaction-utils.ts @@ -420,6 +420,29 @@ class TransactionUtils { return { prioritized, deprioritized }; } + + // Copied from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/bitcoin/bitcoin-api.ts#L324 + public translateScriptPubKeyType(outputType: string): string { + const map = { + 'pubkey': 'p2pk', + 'pubkeyhash': 'p2pkh', + 'scripthash': 'p2sh', + 'witness_v0_keyhash': 'v0_p2wpkh', + 'witness_v0_scripthash': 'v0_p2wsh', + 'witness_v1_taproot': 'v1_p2tr', + 'nonstandard': 'nonstandard', + 'multisig': 'multisig', + 'anchor': 'anchor', + 'nulldata': 'op_return' + }; + + if (map[outputType]) { + return map[outputType]; + } else { + return 'unknown'; + } + } + } export default new TransactionUtils(); diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 461d77bc4..ca76d0e78 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -46,7 +46,7 @@ @if (offlineMode) { Prevouts are not loaded, some fields like fee rate cannot be displayed. } @else { - Could not load prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} + Error loading prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} } } @@ -188,7 +188,7 @@ @if (isLoading) {
-

Loading transaction prevouts ({{ prevoutsLoadedCount }} / {{ prevoutsCount }})

+

Loading transaction prevouts

} \ 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 cac7b595f..c9a0c2544 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef } from '@angular/core'; -import { Transaction } from '@interfaces/electrs.interface'; +import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core'; +import { Transaction, Vout } from '@interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; import { Filter, toFilters } from '../../shared/filters.utils'; import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; @@ -28,8 +28,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { error: string; errorPrevouts: string; hasPrevouts: boolean; - prevoutsLoadedCount: number = 0; - prevoutsCount: number; + missingPrevouts: string[]; isLoadingBroadcast: boolean; errorBroadcast: string; successBroadcast: boolean; @@ -59,7 +58,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { public electrsApi: ElectrsApiService, public websocketService: WebsocketService, public formBuilder: UntypedFormBuilder, - public cd: ChangeDetectorRef, public seoService: SeoService, public apiService: ApiService, public relativeUrlPipe: RelativeUrlPipe, @@ -93,52 +91,38 @@ export class TransactionRawComponent implements OnInit, OnDestroy { return; } - this.prevoutsCount = transaction.vin.filter(input => !input.is_coinbase).length; - if (this.prevoutsCount === 0) { + const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout })); + + if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) { this.hasPrevouts = true; return; } - const txsToFetch: { [txid: string]: number } = transaction.vin.reduce((acc, input) => { - if (!input.is_coinbase) { - acc[input.txid] = (acc[input.txid] || 0) + 1; - } - return acc; - }, {} as { [txid: string]: number }); - try { + this.missingPrevouts = []; - if (Object.keys(txsToFetch).length > 20) { - throw new Error($localize`:@@transaction.too-many-prevouts:Too many transactions to fetch (${Object.keys(txsToFetch).length})`); + const prevouts: { prevout: Vout, tx?: any }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); + + if (prevouts?.length !== prevoutsToFetch.length) { + throw new Error(); } - const fetchedTransactions = await Promise.all( - Object.keys(txsToFetch).map(txid => - firstValueFrom(this.electrsApi.getTransaction$(txid)) - .then(response => { - this.prevoutsLoadedCount += txsToFetch[txid]; - this.cd.markForCheck(); - return response; - }) - ) - ); - - const transactionsMap = fetchedTransactions.reduce((acc, transaction) => { - acc[transaction.txid] = transaction; - return acc; - }, {} as { [txid: string]: any }); - - const prevouts = transaction.vin.map((input, index) => ({ index, prevout: transactionsMap[input.txid]?.vout[input.vout] || null})); - transaction.vin = transaction.vin.map((input, index) => { - if (!input.is_coinbase) { - input.prevout = prevouts.find(p => p.index === index)?.prevout; + if (prevouts[index]) { + input.prevout = prevouts[index].prevout; addInnerScriptsToVin(input); + } else { + this.missingPrevouts.push(`${input.txid}:${input.vout}`); } return input; }); + + if (this.missingPrevouts.length) { + throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); + } + this.hasPrevouts = true; - } catch (error) { + } catch (error) { this.errorPrevouts = error.message; } } @@ -207,6 +191,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { .subscribe((result) => { this.isLoadingBroadcast = false; this.successBroadcast = true; + this.transaction.txid = result; resolve(result); }, (error) => { @@ -232,8 +217,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.adjustedVsize = null; this.filters = []; this.hasPrevouts = false; - this.prevoutsLoadedCount = 0; - this.prevoutsCount = 0; + this.missingPrevouts = []; this.stateService.markBlock$.next({}); this.mempoolBlocksSubscription?.unsubscribe(); } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 3c8cf8807..ce0e67cbf 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -565,6 +565,10 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, ''); } + getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints); + } + // Cache methods async setBlockAuditLoaded(hash: string) { this.blockAuditLoaded[hash] = true;