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
+
+
+
+
+
+
+
+
+
+ Allowed? |
+ TXID |
+ Effective fee rate |
+ Rejection 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');
}