Add backend endpoint to fetch prevouts

This commit is contained in:
natsoni 2024-12-09 23:24:11 +01:00
parent e848d711fc
commit 727f22bc9d
No known key found for this signature in database
GPG Key ID: C65917583181743B
5 changed files with 101 additions and 42 deletions

View File

@ -12,7 +12,7 @@ import backendInfo from '../backend-info';
import transactionUtils from '../transaction-utils'; import transactionUtils from '../transaction-utils';
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
import loadingIndicators from '../loading-indicators'; import loadingIndicators from '../loading-indicators';
import { TransactionExtended } from '../../mempool.interfaces'; import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces';
import logger from '../../logger'; import logger from '../../logger';
import blocks from '../blocks'; import blocks from '../blocks';
import bitcoinClient from './bitcoin-client'; import bitcoinClient from './bitcoin-client';
@ -49,6 +49,7 @@ class BitcoinRoutes {
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .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', this.getBlocksByBulk.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', 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 // Temporarily add txs/package endpoint for all backends until esplora supports it
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) .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(); export default new BitcoinRoutes();

View File

@ -420,6 +420,29 @@ class TransactionUtils {
return { prioritized, deprioritized }; 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(); export default new TransactionUtils();

View File

@ -46,7 +46,7 @@
@if (offlineMode) { @if (offlineMode) {
<span><strong>Prevouts are not loaded, some fields like fee rate cannot be displayed.</strong></span> <span><strong>Prevouts are not loaded, some fields like fee rate cannot be displayed.</strong></span>
} @else { } @else {
<span><strong>Could not load prevouts</strong>. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}</span> <span><strong>Error loading prevouts</strong>. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}</span>
} }
</div> </div>
} }
@ -188,7 +188,7 @@
@if (isLoading) { @if (isLoading) {
<div class="text-center"> <div class="text-center">
<div class="spinner-border text-light mt-2 mb-2"></div> <div class="spinner-border text-light mt-2 mb-2"></div>
<h3 i18n="transaction.error.loading-prevouts">Loading transaction prevouts ({{ prevoutsLoadedCount }} / {{ prevoutsCount }})</h3> <h3 i18n="transaction.error.loading-prevouts">Loading transaction prevouts</h3>
</div> </div>
} }
</div> </div>

View File

@ -1,5 +1,5 @@
import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { Transaction } from '@interfaces/electrs.interface'; import { Transaction, Vout } from '@interfaces/electrs.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Filter, toFilters } from '../../shared/filters.utils'; import { Filter, toFilters } from '../../shared/filters.utils';
import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils';
@ -28,8 +28,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
error: string; error: string;
errorPrevouts: string; errorPrevouts: string;
hasPrevouts: boolean; hasPrevouts: boolean;
prevoutsLoadedCount: number = 0; missingPrevouts: string[];
prevoutsCount: number;
isLoadingBroadcast: boolean; isLoadingBroadcast: boolean;
errorBroadcast: string; errorBroadcast: string;
successBroadcast: boolean; successBroadcast: boolean;
@ -59,7 +58,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
public electrsApi: ElectrsApiService, public electrsApi: ElectrsApiService,
public websocketService: WebsocketService, public websocketService: WebsocketService,
public formBuilder: UntypedFormBuilder, public formBuilder: UntypedFormBuilder,
public cd: ChangeDetectorRef,
public seoService: SeoService, public seoService: SeoService,
public apiService: ApiService, public apiService: ApiService,
public relativeUrlPipe: RelativeUrlPipe, public relativeUrlPipe: RelativeUrlPipe,
@ -93,52 +91,38 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
return; return;
} }
this.prevoutsCount = transaction.vin.filter(input => !input.is_coinbase).length; const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout }));
if (this.prevoutsCount === 0) {
if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) {
this.hasPrevouts = true; this.hasPrevouts = true;
return; 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 { try {
this.missingPrevouts = [];
if (Object.keys(txsToFetch).length > 20) { const prevouts: { prevout: Vout, tx?: any }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch));
throw new Error($localize`:@@transaction.too-many-prevouts:Too many transactions to fetch (${Object.keys(txsToFetch).length})`);
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) => { transaction.vin = transaction.vin.map((input, index) => {
if (!input.is_coinbase) { if (prevouts[index]) {
input.prevout = prevouts.find(p => p.index === index)?.prevout; input.prevout = prevouts[index].prevout;
addInnerScriptsToVin(input); addInnerScriptsToVin(input);
} else {
this.missingPrevouts.push(`${input.txid}:${input.vout}`);
} }
return input; return input;
}); });
if (this.missingPrevouts.length) {
throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`);
}
this.hasPrevouts = true; this.hasPrevouts = true;
} catch (error) { } catch (error) {
this.errorPrevouts = error.message; this.errorPrevouts = error.message;
} }
} }
@ -207,6 +191,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
.subscribe((result) => { .subscribe((result) => {
this.isLoadingBroadcast = false; this.isLoadingBroadcast = false;
this.successBroadcast = true; this.successBroadcast = true;
this.transaction.txid = result;
resolve(result); resolve(result);
}, },
(error) => { (error) => {
@ -232,8 +217,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
this.adjustedVsize = null; this.adjustedVsize = null;
this.filters = []; this.filters = [];
this.hasPrevouts = false; this.hasPrevouts = false;
this.prevoutsLoadedCount = 0; this.missingPrevouts = [];
this.prevoutsCount = 0;
this.stateService.markBlock$.next({}); this.stateService.markBlock$.next({});
this.mempoolBlocksSubscription?.unsubscribe(); this.mempoolBlocksSubscription?.unsubscribe();
} }

View File

@ -565,6 +565,10 @@ export class ApiService {
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, ''); return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
} }
getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable<any> {
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints);
}
// Cache methods // Cache methods
async setBlockAuditLoaded(hash: string) { async setBlockAuditLoaded(hash: string) {
this.blockAuditLoaded[hash] = true; this.blockAuditLoaded[hash] = true;