From 93d95388457a25426e4aa68e0d637b528b74ff73 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sat, 12 Oct 2024 15:56:38 +0900 Subject: [PATCH 1/4] Fix error formatting on core only backend --- .../components/push-transaction/push-transaction.component.ts | 2 +- .../components/test-transactions/test-transactions.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/push-transaction/push-transaction.component.ts b/frontend/src/app/components/push-transaction/push-transaction.component.ts index 03a050dfa..d56ffa2d1 100644 --- a/frontend/src/app/components/push-transaction/push-transaction.component.ts +++ b/frontend/src/app/components/push-transaction/push-transaction.component.ts @@ -59,7 +59,7 @@ export class PushTransactionComponent implements OnInit { }, (error) => { if (typeof error.error === 'string') { - const matchText = error.error.match('"message":"(.*?)"'); + const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"'); this.error = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error); } else if (error.message) { this.error = 'Failed to broadcast transaction, reason: ' + error.message; diff --git a/frontend/src/app/components/test-transactions/test-transactions.component.ts b/frontend/src/app/components/test-transactions/test-transactions.component.ts index c9abeed62..615f635cd 100644 --- a/frontend/src/app/components/test-transactions/test-transactions.component.ts +++ b/frontend/src/app/components/test-transactions/test-transactions.component.ts @@ -74,7 +74,7 @@ export class TestTransactionsComponent implements OnInit { }, (error) => { if (typeof error.error === 'string') { - const matchText = error.error.match('"message":"(.*?)"'); + const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"'); this.error = matchText && matchText[1] || error.error; } else if (error.message) { this.error = error.message; From 9f0b3bd76969a4653b77153b76cf7ce6e453f2fa Mon Sep 17 00:00:00 2001 From: natsoni Date: Sat, 12 Oct 2024 17:38:37 +0900 Subject: [PATCH 2/4] Add submitpackage endpoint --- .../bitcoin/bitcoin-api-abstract-factory.ts | 3 ++- .../src/api/bitcoin/bitcoin-api.interface.ts | 18 ++++++++++++++++++ backend/src/api/bitcoin/bitcoin-api.ts | 6 +++++- backend/src/api/bitcoin/bitcoin.routes.ts | 14 ++++++++++++++ backend/src/api/bitcoin/esplora-api.ts | 6 +++++- backend/src/rpc-api/commands.ts | 1 + 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index a08f43238..95c3ff2b6 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, TestMempoolAcceptResult } from './bitcoin-api.interface'; +import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { @@ -23,6 +23,7 @@ export interface AbstractBitcoinApi { $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise; $sendRawTransaction(rawTransaction: string): Promise; $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise; + $submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: 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 6e8583f6f..5d8371d27 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -218,3 +218,21 @@ export interface TestMempoolAcceptResult { }, ['reject-reason']?: string, } + +export interface SubmitPackageResult { + package_msg: string; + "tx-results": { [wtxid: string]: TxResult }; + "replaced-transactions"?: string[]; +} + +export interface TxResult { + txid: string; + "other-wtxid"?: string; + vsize?: number; + fees?: { + base: number; + "effective-feerate"?: number; + "effective-includes"?: string[]; + }; + error?: string; +} diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 7fa431db6..4cbbf178a 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, TestMempoolAcceptResult } from './bitcoin-api.interface'; +import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; import blocks from '../blocks'; import mempool from '../mempool'; @@ -196,6 +196,10 @@ class BitcoinApi implements AbstractBitcoinApi { } } + $submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise { + return this.bitcoindClient.submitPackage(rawTransactions, maxfeerate ?? undefined, maxburnamount ?? undefined); + } + 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 498003d98..14e5e197d 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -58,6 +58,7 @@ class BitcoinRoutes { .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) + .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) .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) @@ -794,6 +795,19 @@ class BitcoinRoutes { } } + private async $submitPackage(req: Request, res: Response) { + try { + const rawTxs = Common.getTransactionsFromRequest(req); + const maxfeerate = parseFloat(req.query.maxfeerate as string); + const maxburneamount = parseFloat(req.query.maxburneamount as string); + const result = await bitcoinApi.$submitPackage(rawTxs, maxfeerate, maxburneamount); + res.send(result); + } catch (e: any) { + handleError(req, res, 400, e.message && e.code ? 'submitpackage 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 b4ae35da9..7b32115bb 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -5,7 +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'; +import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface'; interface FailoverHost { host: string, @@ -332,6 +332,10 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method not implemented.'); } + $submitPackage(rawTransactions: string[]): 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/rpc-api/commands.ts b/backend/src/rpc-api/commands.ts index 85675230b..89ab9cfe6 100644 --- a/backend/src/rpc-api/commands.ts +++ b/backend/src/rpc-api/commands.ts @@ -83,6 +83,7 @@ module.exports = { signRawTransaction: 'signrawtransaction', // bitcoind v0.7.0+ stop: 'stop', submitBlock: 'submitblock', // bitcoind v0.7.0+ + submitPackage: 'submitpackage', validateAddress: 'validateaddress', verifyChain: 'verifychain', // bitcoind v0.9.0+ verifyMessage: 'verifymessage', From d1741a51c975bdd124639dc6bf554f5417391bf6 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sat, 12 Oct 2024 17:38:48 +0900 Subject: [PATCH 3/4] Add submit package option to tx push page --- .../push-transaction.component.html | 62 ++++++++++++++ .../push-transaction.component.ts | 80 +++++++++++++++++++ .../src/app/interfaces/node-api.interface.ts | 20 ++++- frontend/src/app/services/api.service.ts | 16 +++- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/push-transaction/push-transaction.component.html b/frontend/src/app/components/push-transaction/push-transaction.component.html index dff79afbb..8d8402fd3 100644 --- a/frontend/src/app/components/push-transaction/push-transaction.component.html +++ b/frontend/src/app/components/push-transaction/push-transaction.component.html @@ -9,4 +9,66 @@

{{ error }}

{{ txId }} + @if (network === '' || network === 'testnet' || network === 'testnet4' || network === 'signet') { +
+

Submit Package

+ +
+
+ +
+ + + + +
+ +

{{ errorPackage }}

+

{{ packageMessage }}

+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
Allowed?TXIDEffective fee rateRejection reason
+ @if (result.error == null) { + + } + @else { + + } + + @if (!result.error) { + + } @else { + + } + + + - + + {{ result.error || '-' }} +
+
+ } \ No newline at end of file diff --git a/frontend/src/app/components/push-transaction/push-transaction.component.ts b/frontend/src/app/components/push-transaction/push-transaction.component.ts index d56ffa2d1..cec2f026b 100644 --- a/frontend/src/app/components/push-transaction/push-transaction.component.ts +++ b/frontend/src/app/components/push-transaction/push-transaction.component.ts @@ -7,6 +7,7 @@ import { OpenGraphService } from '../../services/opengraph.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; import { ActivatedRoute, Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { TxResult } from '../../interfaces/node-api.interface'; @Component({ selector: 'app-push-transaction', @@ -19,6 +20,16 @@ export class PushTransactionComponent implements OnInit { txId: string = ''; isLoading = false; + submitTxsForm: UntypedFormGroup; + errorPackage: string = ''; + packageMessage: string = ''; + results: TxResult[] = []; + invalidMaxfeerate = false; + invalidMaxburnamount = false; + isLoadingPackage = false; + + network = this.stateService.network; + constructor( private formBuilder: UntypedFormBuilder, private apiService: ApiService, @@ -35,6 +46,14 @@ export class PushTransactionComponent implements OnInit { txHash: ['', Validators.required], }); + this.submitTxsForm = this.formBuilder.group({ + txs: ['', Validators.required], + maxfeerate: ['', Validators.min(0)], + maxburnamount: ['', Validators.min(0)], + }); + + this.stateService.networkChanged$.subscribe((network) => this.network = network); + this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`); this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`); this.ogService.setManualOgImage('tx-push.jpg'); @@ -70,6 +89,67 @@ export class PushTransactionComponent implements OnInit { }); } + submitTxs() { + let txs: string[] = []; + try { + txs = (this.submitTxsForm.get('txs')?.value as string).split(',').map(hex => hex.trim()); + if (txs?.length === 1) { + this.pushTxForm.get('txHash').setValue(txs[0]); + this.submitTxsForm.get('txs').setValue(''); + this.postTx(); + return; + } + } catch (e) { + this.errorPackage = e?.message; + return; + } + + let maxfeerate; + let maxburnamount; + this.invalidMaxfeerate = false; + this.invalidMaxburnamount = false; + try { + const maxfeerateVal = this.submitTxsForm.get('maxfeerate')?.value; + if (maxfeerateVal != null && maxfeerateVal !== '') { + maxfeerate = parseFloat(maxfeerateVal) / 100_000; + } + } catch (e) { + this.invalidMaxfeerate = true; + } + try { + const maxburnamountVal = this.submitTxsForm.get('maxburnamount')?.value; + if (maxburnamountVal != null && maxburnamountVal !== '') { + maxburnamount = parseInt(maxburnamountVal) / 100_000_000; + } + } catch (e) { + this.invalidMaxburnamount = true; + } + + this.isLoadingPackage = true; + this.errorPackage = ''; + this.results = []; + this.apiService.submitPackage$(txs, maxfeerate === 0.1 ? null : maxfeerate, maxburnamount === 0 ? null : maxburnamount) + .subscribe((result) => { + this.isLoadingPackage = false; + + this.packageMessage = result['package_msg']; + for (let wtxid in result['tx-results']) { + this.results.push(result['tx-results'][wtxid]); + } + + this.submitTxsForm.reset(); + }, + (error) => { + if (typeof error.error?.error === 'string') { + const matchText = error.error.error.replace(/\\/g, '').match('"message":"(.*?)"'); + this.errorPackage = matchText && matchText[1] || error.error.error; + } else if (error.message) { + this.errorPackage = error.message; + } + this.isLoadingPackage = false; + }); + } + private async handleColdcardPushTx(fragmentParams: URLSearchParams): Promise { // maybe conforms to Coldcard nfc-pushtx spec if (fragmentParams && fragmentParams.get('t')) { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 4c7796590..650773794 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -452,4 +452,22 @@ export interface TestMempoolAcceptResult { "effective-includes": string[], }, ['reject-reason']?: string, -} \ No newline at end of file +} + +export interface SubmitPackageResult { + package_msg: string; + "tx-results": { [wtxid: string]: TxResult }; + "replaced-transactions"?: string[]; +} + +export interface TxResult { + txid: string; + "other-wtxid"?: string; + vsize?: number; + fees?: { + base: number; + "effective-feerate"?: number; + "effective-includes"?: string[]; + }; + error?: string; +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index fa52ec707..c536c0bb4 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,8 @@ 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, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult } from '../interfaces/node-api.interface'; + RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, + SubmitPackageResult} 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'; @@ -244,6 +245,19 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + `/api/txs/test${maxfeerate != null ? '?maxfeerate=' + maxfeerate.toFixed(8) : ''}`, rawTxs); } + submitPackage$(rawTxs: string[], maxfeerate?: number, maxburnamount?: number): Observable { + const queryParams = []; + + if (maxfeerate) { + queryParams.push(`maxfeerate=${maxfeerate}`); + } + + if (maxburnamount) { + queryParams.push(`maxburnamount=${maxburnamount}`); + } + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/txs/package' + (queryParams.length > 0 ? `?${queryParams.join('&')}` : ''), rawTxs); + } + getTransactionStatus$(txid: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status'); } From 735ed87b7846b9e85bb2b189858132ae0de7f3bf Mon Sep 17 00:00:00 2001 From: natsoni Date: Sun, 13 Oct 2024 11:14:23 +0900 Subject: [PATCH 4/4] Route submitpackage calls to core on esplora backends --- backend/src/api/bitcoin/bitcoin.routes.ts | 7 ++++--- frontend/src/app/services/api.service.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 14e5e197d..3b33c1ead 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -48,6 +48,8 @@ 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)) + // Temporarily add txs/package endpoint for all backends until esplora supports it + .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; if (config.MEMPOOL.BACKEND !== 'esplora') { @@ -58,7 +60,6 @@ class BitcoinRoutes { .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) - .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) .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) @@ -799,8 +800,8 @@ class BitcoinRoutes { try { const rawTxs = Common.getTransactionsFromRequest(req); const maxfeerate = parseFloat(req.query.maxfeerate as string); - const maxburneamount = parseFloat(req.query.maxburneamount as string); - const result = await bitcoinApi.$submitPackage(rawTxs, maxfeerate, maxburneamount); + const maxburnamount = parseFloat(req.query.maxburnamount as string); + const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined); res.send(result); } catch (e: any) { handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index c536c0bb4..c58a67f0e 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -255,7 +255,7 @@ export class ApiService { if (maxburnamount) { queryParams.push(`maxburnamount=${maxburnamount}`); } - return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/txs/package' + (queryParams.length > 0 ? `?${queryParams.join('&')}` : ''), rawTxs); + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/txs/package' + (queryParams.length > 0 ? `?${queryParams.join('&')}` : ''), rawTxs); } getTransactionStatus$(txid: string): Observable {