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'); }