Add submit package option to tx push page
This commit is contained in:
parent
9f0b3bd769
commit
d1741a51c9
@ -9,4 +9,66 @@
|
|||||||
<p class="red-color d-inline">{{ error }}</p> <a *ngIf="txId" [routerLink]="['/tx/' | relativeUrl, txId]">{{ txId }}</a>
|
<p class="red-color d-inline">{{ error }}</p> <a *ngIf="txId" [routerLink]="['/tx/' | relativeUrl, txId]">{{ txId }}</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@if (network === '' || network === 'testnet' || network === 'testnet4' || network === 'signet') {
|
||||||
|
<br>
|
||||||
|
<h1 class="text-left" style="margin-top: 1rem;" i18n="shared.submit-transactions|Submit Package">Submit Package</h1>
|
||||||
|
|
||||||
|
<form [formGroup]="submitTxsForm" (submit)="submitTxsForm.valid && submitTxs()" novalidate>
|
||||||
|
<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 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}">
|
||||||
|
<label i18n="submitpackage.tx.max-burn-amount">Maximum burn amount (sats)</label>
|
||||||
|
<input type="number" class="form-control input-dark" formControlName="maxburnamount" id="maxburnamount"
|
||||||
|
[value]="0" placeholder="0 sat" [class]="{invalid: invalidMaxburnamount}">
|
||||||
|
<br>
|
||||||
|
<button [disabled]="isLoadingPackage" type="submit" class="btn btn-primary mr-2" i18n="shared.submit-transactions|Submit Package">Submit Package</button>
|
||||||
|
<p *ngIf="errorPackage" class="red-color d-inline">{{ errorPackage }}</p>
|
||||||
|
<p *ngIf="packageMessage" class="d-inline">{{ packageMessage }}</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">
|
||||||
|
@if (result.error == null) {
|
||||||
|
<span>✅</span>
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
<span>❌</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="txid">
|
||||||
|
@if (!result.error) {
|
||||||
|
<a [routerLink]="['/tx/' | relativeUrl, result.txid]"><app-truncate [text]="result.txid"></app-truncate></a>
|
||||||
|
} @else {
|
||||||
|
<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.error || '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
@ -7,6 +7,7 @@ import { OpenGraphService } from '../../services/opengraph.service';
|
|||||||
import { seoDescriptionNetwork } from '../../shared/common.utils';
|
import { seoDescriptionNetwork } from '../../shared/common.utils';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||||
|
import { TxResult } from '../../interfaces/node-api.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-push-transaction',
|
selector: 'app-push-transaction',
|
||||||
@ -19,6 +20,16 @@ export class PushTransactionComponent implements OnInit {
|
|||||||
txId: string = '';
|
txId: string = '';
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
||||||
|
submitTxsForm: UntypedFormGroup;
|
||||||
|
errorPackage: string = '';
|
||||||
|
packageMessage: string = '';
|
||||||
|
results: TxResult[] = [];
|
||||||
|
invalidMaxfeerate = false;
|
||||||
|
invalidMaxburnamount = false;
|
||||||
|
isLoadingPackage = false;
|
||||||
|
|
||||||
|
network = this.stateService.network;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private formBuilder: UntypedFormBuilder,
|
private formBuilder: UntypedFormBuilder,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
@ -35,6 +46,14 @@ export class PushTransactionComponent implements OnInit {
|
|||||||
txHash: ['', Validators.required],
|
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.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.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');
|
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<boolean> {
|
private async handleColdcardPushTx(fragmentParams: URLSearchParams): Promise<boolean> {
|
||||||
// maybe conforms to Coldcard nfc-pushtx spec
|
// maybe conforms to Coldcard nfc-pushtx spec
|
||||||
if (fragmentParams && fragmentParams.get('t')) {
|
if (fragmentParams && fragmentParams.get('t')) {
|
||||||
|
@ -452,4 +452,22 @@ export interface TestMempoolAcceptResult {
|
|||||||
"effective-includes": string[],
|
"effective-includes": string[],
|
||||||
},
|
},
|
||||||
['reject-reason']?: string,
|
['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;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
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 { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
||||||
import { StateService } from './state.service';
|
import { StateService } from './state.service';
|
||||||
import { Transaction } from '../interfaces/electrs.interface';
|
import { Transaction } from '../interfaces/electrs.interface';
|
||||||
@ -244,6 +245,19 @@ export class ApiService {
|
|||||||
return this.httpClient.post<TestMempoolAcceptResult[]>(this.apiBaseUrl + this.apiBasePath + `/api/txs/test${maxfeerate != null ? '?maxfeerate=' + maxfeerate.toFixed(8) : ''}`, rawTxs);
|
return this.httpClient.post<TestMempoolAcceptResult[]>(this.apiBaseUrl + this.apiBasePath + `/api/txs/test${maxfeerate != null ? '?maxfeerate=' + maxfeerate.toFixed(8) : ''}`, rawTxs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
submitPackage$(rawTxs: string[], maxfeerate?: number, maxburnamount?: number): Observable<SubmitPackageResult> {
|
||||||
|
const queryParams = [];
|
||||||
|
|
||||||
|
if (maxfeerate) {
|
||||||
|
queryParams.push(`maxfeerate=${maxfeerate}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxburnamount) {
|
||||||
|
queryParams.push(`maxburnamount=${maxburnamount}`);
|
||||||
|
}
|
||||||
|
return this.httpClient.post<SubmitPackageResult>(this.apiBaseUrl + this.apiBasePath + '/api/txs/package' + (queryParams.length > 0 ? `?${queryParams.join('&')}` : ''), rawTxs);
|
||||||
|
}
|
||||||
|
|
||||||
getTransactionStatus$(txid: string): Observable<any> {
|
getTransactionStatus$(txid: string): Observable<any> {
|
||||||
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status');
|
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status');
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user