Merge pull request #4815 from mempool/mononaut/testmempoolaccept
testmempoolaccept
This commit is contained in:
		
						commit
						89363bfbff
					
				| @ -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<IEsploraApi.ScriptHash>; | ||||
|   $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; | ||||
|   $sendRawTransaction(rawTransaction: string): Promise<string>; | ||||
|   $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise<TestMempoolAcceptResult[]>; | ||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; | ||||
|   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; | ||||
|   $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; | ||||
|  | ||||
| @ -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, | ||||
| } | ||||
|  | ||||
| @ -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<TestMempoolAcceptResult[]> { | ||||
|     if (rawTransactions.length) { | ||||
|       return this.bitcoindClient.testMempoolAccept(rawTransactions, maxfeerate ?? undefined); | ||||
|     } else { | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { | ||||
|     const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); | ||||
|     return { | ||||
|  | ||||
| @ -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) { | ||||
|     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.setHeader('content-type', 'text/plain'); | ||||
|       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(); | ||||
|  | ||||
| @ -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<TestMempoolAcceptResult[]> { | ||||
|     throw new Error('Method not implemented.'); | ||||
|   } | ||||
| 
 | ||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { | ||||
|     return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout); | ||||
|   } | ||||
|  | ||||
| @ -946,6 +946,33 @@ export class Common { | ||||
|     return this.validateTransactionHex(matches[1].toLowerCase()); | ||||
|   } | ||||
| 
 | ||||
|   static getTransactionsFromRequest(req: Request, limit: number = 25): string[] { | ||||
|     if (!Array.isArray(req.body) || req.body.some(hex => typeof hex !== 'string')) { | ||||
|       throw Object.assign(new Error('Invalid request body (should be an array of hexadecimal strings)'), { code: -1 }); | ||||
|     } | ||||
| 
 | ||||
|     if (limit && req.body.length > limit) { | ||||
|       throw Object.assign(new Error('Exceeded maximum of 25 transactions'), { code: -1 }); | ||||
|     } | ||||
| 
 | ||||
|     const txs = req.body; | ||||
| 
 | ||||
|     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
 | ||||
| 
 | ||||
|  | ||||
| @ -131,6 +131,7 @@ class Server { | ||||
|       }) | ||||
|       .use(express.urlencoded({ extended: true })) | ||||
|       .use(express.text({ type: ['text/plain', 'application/base64'] })) | ||||
|       .use(express.json()) | ||||
|       ; | ||||
| 
 | ||||
|     if (config.DATABASE.ENABLED && config.FIAT_PRICE.ENABLED) { | ||||
|  | ||||
| @ -0,0 +1,53 @@ | ||||
| <div class="container-xl"> | ||||
|   <h1 class="text-left" i18n="shared.test-transactions|Test Transactions">Test Transactions</h1> | ||||
| 
 | ||||
|   <form [formGroup]="testTxsForm" (submit)="testTxsForm.valid && testTxs()" novalidate> | ||||
|     <label for="maxfeerate" i18n="test.tx.raw-hex">Raw hex</label> | ||||
|     <div class="mb-3"> | ||||
|       <textarea formControlName="txs" class="form-control" rows="5" i18n-placeholder="transaction.test-transactions" placeholder="Comma-separated list of raw transactions"></textarea> | ||||
|     </div> | ||||
|     <label for="maxfeerate" i18n="test.tx.max-fee-rate">Maximum fee rate (sat/vB)</label> | ||||
|     <input type="number" class="form-control input-dark" formControlName="maxfeerate" id="maxfeerate" | ||||
|         [value]="10000" placeholder="10,000 s/vb" [class]="{invalid: invalidMaxfeerate}"> | ||||
|     <br> | ||||
|     <button [disabled]="isLoading" type="submit" class="btn btn-primary mr-2" i18n="shared.test-transactions|Test Transactions">Test Transactions</button> | ||||
|     <p class="red-color d-inline">{{ error }}</p> | ||||
|   </form> | ||||
| 
 | ||||
|   <br> | ||||
| 
 | ||||
|   <div class="box" *ngIf="results?.length"> | ||||
|     <table class="accept-results table table-fixed table-borderless table-striped"> | ||||
|       <tbody> | ||||
|         <tr> | ||||
|           <th class="allowed" i18n="test-tx.is-allowed">Allowed?</th> | ||||
|           <th class="txid" i18n="dashboard.latest-transactions.txid">TXID</th> | ||||
|           <th class="rate" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</th> | ||||
|           <th class="reason" i18n="test-tx.rejection-reason">Rejection reason</th> | ||||
|         </tr> | ||||
|         <ng-container *ngFor="let result of results;"> | ||||
|           <tr> | ||||
|             <td class="allowed"> | ||||
|               <ng-container [ngSwitch]="result.allowed"> | ||||
|                 <span *ngSwitchCase="true">✅</span> | ||||
|                 <span *ngSwitchCase="false">❌</span> | ||||
|                 <span *ngSwitchDefault>-</span> | ||||
|               </ng-container> | ||||
|             </td> | ||||
|             <td class="txid"> | ||||
|               <app-truncate [text]="result.txid || '-'"></app-truncate> | ||||
|             </td> | ||||
|             <td class="rate"> | ||||
|               <app-fee-rate *ngIf="result.fees?.['effective-feerate'] != null" [fee]="result.fees?.['effective-feerate'] * 100000"></app-fee-rate> | ||||
|               <span *ngIf="result.fees?.['effective-feerate'] == null">-</span> | ||||
|             </td> | ||||
|             <td class="reason"> | ||||
|               {{ result['reject-reason'] || '-' }} | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-container> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,34 @@ | ||||
| .accept-results { | ||||
|   td, th { | ||||
|     &.allowed { | ||||
|       width: 10%; | ||||
|       text-align: center; | ||||
|     } | ||||
|     &.txid { | ||||
|       width: 50%; | ||||
|     } | ||||
|     &.rate { | ||||
|       width: 20%; | ||||
|       text-align: right; | ||||
|       white-space: wrap; | ||||
|     } | ||||
|     &.reason { | ||||
|       width: 20%; | ||||
|       text-align: right; | ||||
|       white-space: wrap; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @media (max-width: 950px) { | ||||
|     table-layout: auto; | ||||
| 
 | ||||
|     td, th { | ||||
|       &.allowed { | ||||
|         width: 100px; | ||||
|       } | ||||
|       &.txid { | ||||
|         max-width: 200px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,86 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { OpenGraphService } from '../../services/opengraph.service'; | ||||
| import { TestMempoolAcceptResult } from '../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-test-transactions', | ||||
|   templateUrl: './test-transactions.component.html', | ||||
|   styleUrls: ['./test-transactions.component.scss'] | ||||
| }) | ||||
| export class TestTransactionsComponent implements OnInit { | ||||
|   testTxsForm: UntypedFormGroup; | ||||
|   error: string = ''; | ||||
|   results: TestMempoolAcceptResult[] = []; | ||||
|   isLoading = false; | ||||
|   invalidMaxfeerate = false; | ||||
| 
 | ||||
|   constructor( | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|     private ogService: OpenGraphService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.testTxsForm = this.formBuilder.group({ | ||||
|       txs: ['', Validators.required], | ||||
|       maxfeerate: ['', Validators.min(0)] | ||||
|     }); | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@meta.title.test-txs:Test Transactions`); | ||||
|     this.ogService.setManualOgImage('tx-push.jpg'); | ||||
|   } | ||||
| 
 | ||||
|   testTxs() { | ||||
|     let txs: string[] = []; | ||||
|     try { | ||||
|       txs = (this.testTxsForm.get('txs')?.value as string).split(',').map(hex => hex.trim()); | ||||
|       if (!txs?.length) { | ||||
|         this.error = 'At least one transaction is required'; | ||||
|         return; | ||||
|       } else if (txs.length > 25) { | ||||
|         this.error = 'Exceeded maximum of 25 transactions'; | ||||
|         return; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       this.error = e?.message; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let maxfeerate; | ||||
|     this.invalidMaxfeerate = false; | ||||
|     try { | ||||
|       const maxfeerateVal = this.testTxsForm.get('maxfeerate')?.value; | ||||
|       if (maxfeerateVal != null && maxfeerateVal !== '') { | ||||
|         maxfeerate = parseFloat(maxfeerateVal) / 100_000; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       this.invalidMaxfeerate = true; | ||||
|     } | ||||
| 
 | ||||
|     this.isLoading = true; | ||||
|     this.error = ''; | ||||
|     this.results = []; | ||||
|     this.apiService.testTransactions$(txs, maxfeerate === 0.1 ? null : maxfeerate) | ||||
|       .subscribe((result) => { | ||||
|         this.isLoading = false; | ||||
|         this.results = result || []; | ||||
|         this.testTxsForm.reset(); | ||||
|       }, | ||||
|       (error) => { | ||||
|         if (typeof error.error === 'string') { | ||||
|           const matchText = error.error.match('"message":"(.*?)"'); | ||||
|           this.error = matchText && matchText[1] || error.error; | ||||
|         } else if (error.message) { | ||||
|           this.error = error.message; | ||||
|         } | ||||
|         this.isLoading = false; | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -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, | ||||
| } | ||||
| @ -6,6 +6,7 @@ import { SharedModule } from './shared/shared.module'; | ||||
| 
 | ||||
| import { StartComponent } from './components/start/start.component'; | ||||
| import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; | ||||
| import { TestTransactionsComponent } from './components/test-transactions/test-transactions.component'; | ||||
| import { CalculatorComponent } from './components/calculator/calculator.component'; | ||||
| import { BlocksList } from './components/blocks-list/blocks-list.component'; | ||||
| import { RbfList } from './components/rbf-list/rbf-list.component'; | ||||
| @ -30,6 +31,10 @@ const routes: Routes = [ | ||||
|         path: 'tx/push', | ||||
|         component: PushTransactionComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'tx/test', | ||||
|         component: TestTransactionsComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'about', | ||||
|         loadChildren: () => import('./components/about/about.module').then(m => m.AboutModule), | ||||
|  | ||||
| @ -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<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'}); | ||||
|   } | ||||
| 
 | ||||
|   testTransactions$(rawTxs: string[], maxfeerate?: number): Observable<TestMempoolAcceptResult[]> { | ||||
|     return this.httpClient.post<TestMempoolAcceptResult[]>(this.apiBaseUrl + this.apiBasePath + `/api/txs/test${maxfeerate != null ? '?maxfeerate=' + maxfeerate.toFixed(8) : ''}`, rawTxs); | ||||
|   } | ||||
| 
 | ||||
|   getTransactionStatus$(txid: string): Observable<any> { | ||||
|     return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status'); | ||||
|   } | ||||
|  | ||||
| @ -49,6 +49,7 @@ | ||||
|           <p><a *ngIf="env.LIGHTNING" [routerLink]="['/lightning' | relativeUrl]" i18n="master-page.lightning">Lightning Explorer</a></p> | ||||
|           <p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p> | ||||
|           <p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p> | ||||
|           <p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p> | ||||
|           <p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p> | ||||
|           <p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p> | ||||
|         </div> | ||||
|  | ||||
| @ -70,6 +70,7 @@ import { AddressTransactionsWidgetComponent } from '../components/address-transa | ||||
| import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component'; | ||||
| import { RbfTimelineTooltipComponent } from '../components/rbf-timeline/rbf-timeline-tooltip.component'; | ||||
| import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; | ||||
| import { TestTransactionsComponent } from '../components/test-transactions/test-transactions.component'; | ||||
| import { AssetsFeaturedComponent } from '../components/assets/assets-featured/assets-featured.component'; | ||||
| import { AssetGroupComponent } from '../components/assets/asset-group/asset-group.component'; | ||||
| import { AssetCirculationComponent } from '../components/asset-circulation/asset-circulation.component'; | ||||
| @ -180,6 +181,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     RbfTimelineComponent, | ||||
|     RbfTimelineTooltipComponent, | ||||
|     PushTransactionComponent, | ||||
|     TestTransactionsComponent, | ||||
|     AssetsNavComponent, | ||||
|     AssetsFeaturedComponent, | ||||
|     AssetGroupComponent, | ||||
| @ -318,6 +320,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     RbfTimelineComponent, | ||||
|     RbfTimelineTooltipComponent, | ||||
|     PushTransactionComponent, | ||||
|     TestTransactionsComponent, | ||||
|     AssetsNavComponent, | ||||
|     AssetsFeaturedComponent, | ||||
|     AssetGroupComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user