From c7cb7d1ac4e17c3902fdda350e08038595df7890 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 23 Mar 2024 11:27:28 +0000 Subject: [PATCH] Add testmempoolaccept API endpoint --- .../bitcoin/bitcoin-api-abstract-factory.ts | 3 ++- .../src/api/bitcoin/bitcoin-api.interface.ts | 13 +++++++++++ backend/src/api/bitcoin/bitcoin-api.ts | 10 +++++++- backend/src/api/bitcoin/bitcoin.routes.ts | 14 +++++++++++ backend/src/api/bitcoin/esplora-api.ts | 5 ++++ backend/src/api/common.ts | 23 +++++++++++++++++++ .../src/app/interfaces/node-api.interface.ts | 13 +++++++++++ frontend/src/app/services/api.service.ts | 8 +++++-- 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index cc0c801b5..4ec50f4b3 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -1,4 +1,4 @@ -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { @@ -22,6 +22,7 @@ export interface AbstractBitcoinApi { $getScriptHash(scripthash: string): Promise; $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise; $sendRawTransaction(rawTransaction: string): Promise; + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise; $getOutspend(txId: string, vout: number): Promise; $getOutspends(txId: string): Promise; $getBatchedOutspends(txId: string[]): Promise; diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index e176566d7..6e8583f6f 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -205,3 +205,16 @@ export namespace IBitcoinApi { "utxo_size_inc": number; } } + +export interface TestMempoolAcceptResult { + txid: string, + wtxid: string, + allowed?: boolean, + vsize?: number, + fees?: { + base: number, + "effective-feerate": number, + "effective-includes": string[], + }, + ['reject-reason']?: string, +} diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index d19eb06ac..c3304b432 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -1,6 +1,6 @@ import * as bitcoinjs from 'bitcoinjs-lib'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; import blocks from '../blocks'; import mempool from '../mempool'; @@ -174,6 +174,14 @@ class BitcoinApi implements AbstractBitcoinApi { return this.bitcoindClient.sendRawTransaction(rawTransaction); } + async $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + if (rawTransactions.length) { + return this.bitcoindClient.testMempoolAccept(rawTransactions, maxfeerate ?? undefined); + } else { + return []; + } + } + async $getOutspend(txId: string, vout: number): Promise { const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); return { diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index a82cda3f0..ab5b0a73a 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -55,6 +55,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mempool/recent', this.getRecentMempoolTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId', this.getTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'tx', this.$postTransaction) + .post(config.MEMPOOL.API_URL_PREFIX + 'txs/test', this.$testTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) @@ -749,6 +750,19 @@ class BitcoinRoutes { } } + private async $testTransactions(req: Request, res: Response) { + res.setHeader('content-type', 'text/plain'); + try { + const rawTxs = Common.getTransactionsFromRequest(req); + const maxfeerate = parseFloat(req.query.maxfeerate as string); + const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); + res.send(result); + } catch (e: any) { + res.status(400).send(e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) + : (e.message || 'Error')); + } + } + } export default new BitcoinRoutes(); diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index a9dadf4a0..90e50d4c2 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -5,6 +5,7 @@ import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-fact import { IEsploraApi } from './esplora-api.interface'; import logger from '../../logger'; import { Common } from '../common'; +import { TestMempoolAcceptResult } from './bitcoin-api.interface'; interface FailoverHost { host: string, @@ -327,6 +328,10 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method not implemented.'); } + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + throw new Error('Method not implemented.'); + } + $getOutspend(txId: string, vout: number): Promise { return this.failoverRouter.$get('/tx/' + txId + '/outspend/' + vout); } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 92dfceb52..ddbe98ec5 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -946,6 +946,29 @@ export class Common { return this.validateTransactionHex(matches[1].toLowerCase()); } + static getTransactionsFromRequest(req: Request): string[] { + if (typeof req.body !== 'string') { + throw Object.assign(new Error('Non-string request body'), { code: -1 }); + } + + const txs = req.body.split(','); + + return txs.map(rawTx => { + // Support both upper and lower case hex + // Support both txHash= Form and direct API POST + const reg = /^((?:[a-fA-F0-9]{2})+)$/; + const matches = reg.exec(rawTx); + if (!matches || !matches[1]) { + throw Object.assign(new Error('Invalid hex string'), { code: -2 }); + } + + // Guaranteed to be a hex string of multiple of 2 + // Guaranteed to be lower case + // Guaranteed to pass validation (see function below) + return this.validateTransactionHex(matches[1].toLowerCase()); + }); + } + private static validateTransactionHex(txhex: string): string { // Do not mutate txhex diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 343d43a2e..ed7f5d9d4 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -423,4 +423,17 @@ export interface AccelerationInfo { effective_fee: number, boost_rate: number, boost_cost: number, +} + +export interface TestMempoolAcceptResult { + txid: string, + wtxid: string, + allowed?: boolean, + vsize?: number, + fees?: { + base: number, + "effective-feerate": number, + "effective-includes": string[], + }, + ['reject-reason']?: string, } \ No newline at end of file diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 145a8705e..f5e59d0c9 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; -import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, - PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration, AccelerationHistoryParams, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, + RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult } from '../interfaces/node-api.interface'; import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs'; import { StateService } from './state.service'; import { Transaction } from '../interfaces/electrs.interface'; @@ -238,6 +238,10 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'}); } + testTransactions$(hexPayload: string): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/txs/test', hexPayload, { responseType: 'text' as 'json'}); + } + getTransactionStatus$(txid: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status'); }